-
Notifications
You must be signed in to change notification settings - Fork 714
feature: IMDReader Integration #4923
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
a6f2bb2
a67cbfb
8c00bc1
bc88b7b
cf15cf9
7aed3b4
b5ff03d
6263151
efbb903
2ff3935
073430b
45ad921
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
""" | ||
IMDReader --- :mod:`MDAnalysis.coordinates.IMD` | ||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||
Read and analyze simulation data interactively using `IMDClient`_. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A bit more detail here for the documentation would be good. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Need a complete example people can follow |
||
.. _IMDClient: https://github.com/Becksteinlab/imdclient | ||
Units | ||
----- | ||
The units in IMDv3 are fixed. | ||
.. list-table:: | ||
:widths: 10 10 | ||
:header-rows: 1 | ||
* - Measurement | ||
- Unit | ||
* - Length | ||
- angstrom | ||
* - Velocity | ||
- angstrom/picosecond | ||
* - Force | ||
- kilojoules/(mol*angstrom) | ||
* - Time | ||
- picosecond | ||
* - Energy | ||
- kilojoules/mol | ||
Classes | ||
------- | ||
.. autoclass:: IMDReader | ||
:members: | ||
:inherited-members: | ||
""" | ||
|
||
import numpy as np | ||
import logging | ||
import warnings | ||
|
||
from MDAnalysis.coordinates import core | ||
from MDAnalysis.lib.util import store_init_arguments | ||
from MDAnalysis.coordinates.base import StreamReaderBase | ||
|
||
|
||
from packaging.version import Version | ||
|
||
MIN_IMDCLIENT_VERSION = Version("0.1.4") | ||
|
||
try: | ||
import imdclient | ||
from imdclient.IMDClient import IMDClient | ||
hmacdope marked this conversation as resolved.
Show resolved
Hide resolved
|
||
except ImportError: | ||
HAS_IMDCLIENT = False | ||
imdclient_version = Version("0.0.0") | ||
|
||
# Allow building documentation without imdclient | ||
import types | ||
|
||
class MockIMDClient: | ||
pass | ||
imdclient = types.ModuleType("imdclient") | ||
imdclient.IMDClient = MockIMDClient | ||
imdclient.__version__ = "0.0.0" | ||
|
||
else: | ||
HAS_IMDCLIENT = True | ||
imdclient_version = Version(imdclient.__version__) | ||
|
||
# Check for compatibility: currently needs to be >=0.1.4 | ||
if imdclient_version < MIN_IMDCLIENT_VERSION: | ||
warnings.warn( | ||
f"imdclient version {imdclient_version} is too old; " | ||
f"need at least {imdclient_version}, Your installed version of " | ||
"imdclient will NOT be used.", | ||
category=RuntimeWarning, | ||
) | ||
HAS_IMDCLIENT = False | ||
|
||
logger = logging.getLogger("MDAnalysis.coordinates.IMDReader") | ||
|
||
|
||
class IMDReader(StreamReaderBase): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I'm getting confused about which PR is for which thing. @orbeckst given our discussion earlier this week, and your comment above which I take to be "IMDClient is still in flux", does it not make sense for the IMDReader to exist upstream and then just import it here? (edit: here my intent is "well then you could make releases and it wouldn't be limited to MDA release frequency"). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have to split IMDReader from imdclient and make a version of imdclient without IMDReader (which is in the works Becksteinlab/imdclient#54 ). At the same time we are moving what was split off into coordinates.IMD. Amru is working on both at the moment. The way IMDReader depends on imdclient is not the problem, and imdclient itself is also pretty stable, it's just that the tests for imdclient have made use of a lot of MDAnalysis/IMDReader for convenience, and we now have to rewrite some of these tests to use bare-bones python. |
||
""" | ||
Reader for IMD protocol packets. | ||
Parameters | ||
---------- | ||
filename : a string of the form "imd://host:port" where host is the hostname | ||
or IP address of the listening GROMACS server and port | ||
is the port number. | ||
n_atoms : int (optional) | ||
number of atoms in the system. defaults to number of atoms | ||
in the topology. Don't set this unless you know what you're doing. | ||
kwargs : dict (optional) | ||
keyword arguments passed to the constructed :class:`IMDClient` | ||
""" | ||
|
||
format = "IMD" | ||
one_pass = True | ||
|
||
@store_init_arguments | ||
def __init__( | ||
self, | ||
filename, | ||
convert_units=True, | ||
n_atoms=None, | ||
**kwargs, | ||
): | ||
if not HAS_IMDCLIENT: | ||
raise ImportError( | ||
"IMDReader requires the imdclient package. " | ||
"Please install it with 'pip install imdclient'." | ||
) | ||
|
||
super(IMDReader, self).__init__(filename, **kwargs) | ||
|
||
self._imdclient = None | ||
logger.debug("IMDReader initializing") | ||
|
||
if n_atoms is None: | ||
raise ValueError("IMDReader: n_atoms must be specified") | ||
self.n_atoms = n_atoms | ||
|
||
host, port = parse_host_port(filename) | ||
|
||
# This starts the simulation | ||
self._imdclient = IMDClient(host, port, n_atoms, **kwargs) | ||
|
||
imdsinfo = self._imdclient.get_imdsessioninfo() | ||
# NOTE: after testing phase, fail out on IMDv2 | ||
|
||
self.ts = self._Timestep( | ||
self.n_atoms, | ||
positions=imdsinfo.positions, | ||
velocities=imdsinfo.velocities, | ||
forces=imdsinfo.forces, | ||
**self._ts_kwargs, | ||
) | ||
|
||
self._frame = -1 | ||
|
||
try: | ||
self._read_next_timestep() | ||
except StopIteration as e: | ||
raise RuntimeError("IMDReader: No data found in stream") from e | ||
|
||
def _read_frame(self, frame): | ||
|
||
try: | ||
imdf = self._imdclient.get_imdframe() | ||
except EOFError as e: | ||
raise e | ||
|
||
self._frame = frame | ||
self._load_imdframe_into_ts(imdf) | ||
|
||
logger.debug("IMDReader: Loaded frame %d", self._frame) | ||
return self.ts | ||
|
||
def _load_imdframe_into_ts(self, imdf): | ||
self.ts.frame = self._frame | ||
if imdf.time is not None: | ||
self.ts.time = imdf.time | ||
# NOTE: timestep.pyx "dt" method is suspicious bc it uses "new" keyword for a float | ||
self.ts.data["dt"] = imdf.dt | ||
self.ts.data["step"] = imdf.step | ||
if imdf.energies is not None: | ||
self.ts.data.update( | ||
{k: v for k, v in imdf.energies.items() if k != "step"} | ||
) | ||
if imdf.box is not None: | ||
self.ts.dimensions = core.triclinic_box(*imdf.box) | ||
if imdf.positions is not None: | ||
# must call copy because reference is expected to reset | ||
# see 'test_frame_collect_all_same' in MDAnalysisTests.coordinates.base | ||
np.copyto(self.ts.positions, imdf.positions) | ||
if imdf.velocities is not None: | ||
np.copyto(self.ts.velocities, imdf.velocities) | ||
if imdf.forces is not None: | ||
np.copyto(self.ts.forces, imdf.forces) | ||
|
||
@staticmethod | ||
def _format_hint(thing): | ||
try: | ||
parse_host_port(thing) | ||
except: | ||
return False | ||
return HAS_IMDCLIENT and True | ||
|
||
def close(self): | ||
"""Gracefully shut down the reader. Stops the producer thread.""" | ||
logger.debug("IMDReader close() called") | ||
if self._imdclient is not None: | ||
self._imdclient.stop() | ||
# NOTE: removeme after testing | ||
logger.debug("IMDReader shut down gracefully.") | ||
|
||
# NOTE: think of other edge cases as well- should be robust | ||
def parse_host_port(filename): | ||
if not filename.startswith("imd://"): | ||
raise ValueError("IMDReader: URL must be in the format 'imd://host:port'") | ||
# Check if the format is correct | ||
parts = filename.split("imd://")[1].split(":") | ||
if len(parts) == 2: | ||
host = parts[0] | ||
try: | ||
port = int(parts[1]) | ||
return (host, port) | ||
except ValueError as e: | ||
raise ValueError("IMDReader: Port must be an integer") from e | ||
else: | ||
raise ValueError("IMDReader: URL must be in the format 'imd://host:port'") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
.. automodule:: MDAnalysis.coordinates.IMD |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -76,6 +76,7 @@ extra_formats = [ | |
"pytng>=0.2.3", | ||
"gsd>3.0.0", | ||
"rdkit>=2020.03.1", | ||
"imdclient", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We almost certainly need to add a minimal version. |
||
] | ||
analysis = [ | ||
"biopython>=1.80", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,390 @@ | ||
"""Test for MDAnalysis trajectory reader expectations | ||
""" | ||
import numpy as np | ||
import logging | ||
import pytest | ||
from MDAnalysis.transformations import translate | ||
import pickle | ||
import MDAnalysis as mda | ||
from numpy.testing import ( | ||
assert_almost_equal, | ||
assert_array_almost_equal, | ||
assert_equal, | ||
assert_allclose, | ||
) | ||
import sys | ||
from types import ModuleType | ||
from MDAnalysis.coordinates.IMD import HAS_IMDCLIENT | ||
|
||
if HAS_IMDCLIENT: | ||
import imdclient | ||
from imdclient.tests.utils import ( | ||
get_free_port, | ||
create_default_imdsinfo_v3, | ||
) | ||
from imdclient.tests.server import InThreadIMDServer | ||
|
||
from MDAnalysis.coordinates.IMD import IMDReader | ||
|
||
from MDAnalysisTests.datafiles import ( | ||
COORDINATES_TOPOLOGY, | ||
COORDINATES_TRR, | ||
COORDINATES_H5MD, | ||
) | ||
|
||
from MDAnalysisTests.coordinates.base import ( | ||
MultiframeReaderTest, | ||
BaseReference, | ||
assert_timestep_almost_equal, | ||
) | ||
|
||
|
||
def test_HAS_IMDCLIENT_too_old(): | ||
# mock a version of imdclient that is too old | ||
module_name = "imdclient" | ||
|
||
sys.modules.pop(module_name, None) | ||
sys.modules.pop("MDAnalysis.coordinates.IMD", None) | ||
|
||
mocked_module = ModuleType(module_name) | ||
# too old version | ||
mocked_module.__version__ = "0.1.0" | ||
sys.modules[module_name] = mocked_module | ||
|
||
from MDAnalysis.coordinates.IMD import HAS_IMDCLIENT | ||
assert not HAS_IMDCLIENT | ||
|
||
|
||
|
||
def test_HAS_IMDCLIENT_new_enough(): | ||
module_name = "imdclient" | ||
sys.modules.pop(module_name, None) | ||
sys.modules.pop("MDAnalysis.coordinates.IMD", None) | ||
|
||
mocked_module = ModuleType(module_name) | ||
# new enough version | ||
mocked_module.__version__ = "0.1.4" | ||
sys.modules[module_name] = mocked_module | ||
|
||
from MDAnalysis.coordinates.IMD import HAS_IMDCLIENT | ||
assert HAS_IMDCLIENT | ||
|
||
|
||
|
||
|
||
@pytest.mark.skipif(not HAS_IMDCLIENT, reason="IMDClient not installed") | ||
class IMDReference(BaseReference): | ||
def __init__(self): | ||
super(IMDReference, self).__init__() | ||
self.port = get_free_port() | ||
# Serve TRR traj data via the server | ||
traj = mda.coordinates.TRR.TRRReader(COORDINATES_TRR) | ||
self.server = InThreadIMDServer(traj) | ||
self.server.set_imdsessioninfo(create_default_imdsinfo_v3()) | ||
|
||
self.n_atoms = traj.n_atoms | ||
self.prec = 3 | ||
|
||
self.trajectory = f"imd://localhost:{self.port}" | ||
self.topology = COORDINATES_TOPOLOGY | ||
self.changing_dimensions = True | ||
self.reader = IMDReader | ||
|
||
self.first_frame.velocities = self.first_frame.positions / 10 | ||
self.first_frame.forces = self.first_frame.positions / 100 | ||
|
||
self.second_frame.velocities = self.second_frame.positions / 10 | ||
self.second_frame.forces = self.second_frame.positions / 100 | ||
|
||
self.last_frame.velocities = self.last_frame.positions / 10 | ||
self.last_frame.forces = self.last_frame.positions / 100 | ||
|
||
self.jump_to_frame.velocities = self.jump_to_frame.positions / 10 | ||
self.jump_to_frame.forces = self.jump_to_frame.positions / 100 | ||
|
||
def iter_ts(self, i): | ||
ts = self.first_frame.copy() | ||
ts.positions = 2**i * self.first_frame.positions | ||
ts.velocities = ts.positions / 10 | ||
ts.forces = ts.positions / 100 | ||
ts.time = i | ||
ts.frame = i | ||
return ts | ||
|
||
|
||
@pytest.mark.skipif(not HAS_IMDCLIENT, reason="IMDClient not installed") | ||
class TestIMDReaderBaseAPI(MultiframeReaderTest): | ||
|
||
@pytest.fixture(scope='function') | ||
def ref(self): | ||
"""Not a static method like in base class- need new server for each test""" | ||
return IMDReference() | ||
|
||
@pytest.fixture() | ||
def reader(self, ref): | ||
# This will start the test IMD Server, waiting for a connection | ||
# to then send handshake & first frame | ||
ref.server.handshake_sequence("localhost", ref.port) | ||
# This will connect to the test IMD Server and read the first frame | ||
reader = ref.reader(ref.trajectory, n_atoms=ref.n_atoms) | ||
# Send the rest of the frames- small enough to all fit in socket itself | ||
ref.server.send_frames(1, 5) | ||
|
||
reader.add_auxiliary( | ||
"lowf", | ||
ref.aux_lowf, | ||
dt=ref.aux_lowf_dt, | ||
initial_time=0, | ||
time_selector=None, | ||
) | ||
reader.add_auxiliary( | ||
"highf", | ||
ref.aux_highf, | ||
dt=ref.aux_highf_dt, | ||
initial_time=0, | ||
time_selector=None, | ||
) | ||
return reader | ||
|
||
@pytest.fixture() | ||
def transformed(self, ref): | ||
# This will start the test IMD Server, waiting for a connection | ||
# to then send handshake & first frame | ||
ref.server.handshake_sequence("localhost", ref.port) | ||
# This will connect to the test IMD Server and read the first frame | ||
transformed = ref.reader(ref.trajectory, n_atoms=ref.n_atoms) | ||
# Send the rest of the frames- small enough to all fit in socket itself | ||
ref.server.send_frames(1, 5) | ||
transformed.add_transformations( | ||
translate([1, 1, 1]), translate([0, 0, 0.33]) | ||
) | ||
return transformed | ||
|
||
def test_n_frames(self, reader, ref): | ||
amruthesht marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you add for these skips a short comment of why this test is skipped i.e it is not part of the API and why |
||
pass | ||
|
||
def test_first_frame(self, ref, reader): | ||
# don't rewind here as in inherited base test | ||
assert_timestep_almost_equal( | ||
reader.ts, ref.first_frame, decimal=ref.prec | ||
) | ||
|
||
def test_get_writer_1(self, ref, reader, tmpdir): | ||
pass | ||
|
||
def test_get_writer_2(self, ref, reader, tmpdir): | ||
pass | ||
|
||
def test_total_time(self, reader, ref): | ||
pass | ||
|
||
def test_changing_dimensions(self, ref, reader): | ||
pass | ||
|
||
def test_iter(self, ref, reader): | ||
for i, ts in enumerate(reader): | ||
assert_timestep_almost_equal(ts, ref.iter_ts(i), decimal=ref.prec) | ||
|
||
def test_first_dimensions(self, ref, reader): | ||
# don't rewind here as in inherited base test | ||
if ref.dimensions is None: | ||
assert reader.ts.dimensions is None | ||
else: | ||
assert_allclose( | ||
reader.ts.dimensions, | ||
ref.dimensions, | ||
rtol=0, | ||
atol=1.5 * 10**(-ref.prec) | ||
) | ||
|
||
|
||
def test_volume(self, ref, reader): | ||
# don't rewind here as in inherited base test | ||
vol = reader.ts.volume | ||
# Here we can only be sure about the numbers upto the decimal point due | ||
# to floating point impressions. | ||
assert_allclose(vol, ref.volume, rtol=0, atol=1.5e0) | ||
|
||
def test_reload_auxiliaries_from_description(self, ref, reader): | ||
pass | ||
|
||
def test_stop_iter(self, reader): | ||
pass | ||
|
||
def test_iter_rewinds(self, reader): | ||
pass | ||
|
||
def test_timeseries_shape(self, reader,): | ||
pass | ||
|
||
def test_timeseries_asel_shape(self, reader): | ||
hmacdope marked this conversation as resolved.
Show resolved
Hide resolved
|
||
pass | ||
|
||
def test_timeseries_values(self, reader): | ||
pass | ||
|
||
def test_transformations_2iter(self, ref, transformed): | ||
pass | ||
|
||
def test_transformations_slice(self, ref, transformed): | ||
pass | ||
|
||
def test_transformations_switch_frame(self, ref, transformed): | ||
pass | ||
|
||
def test_transformation_rewind(self, ref, transformed): | ||
pass | ||
|
||
def test_copy(self, ref, transformed): | ||
pass | ||
|
||
def test_pickle_reader(self, reader): | ||
pass | ||
|
||
def test_pickle_next_ts_reader(self, reader): | ||
pass | ||
|
||
def test_pickle_last_ts_reader(self, reader): | ||
pass | ||
|
||
def test_transformations_copy(self, ref, transformed): | ||
pass | ||
|
||
def test_timeseries_empty_asel(self, reader): | ||
pass | ||
|
||
def test_timeseries_empty_atomgroup(self, reader): | ||
pass | ||
|
||
def test_timeseries_asel_warns_deprecation(self, reader): | ||
pass | ||
|
||
def test_timeseries_atomgroup(self, reader): | ||
pass | ||
|
||
def test_timeseries_atomgroup_asel_mutex(self, reader): | ||
pass | ||
|
||
def test_last_frame(self, ref, reader): | ||
pass | ||
|
||
def test_go_over_last_frame(self, ref, reader): | ||
pass | ||
|
||
def test_frame_jump(self, ref, reader): | ||
pass | ||
|
||
def test_frame_jump_issue1942(self, ref, reader): | ||
pass | ||
|
||
def test_next_gives_second_frame(self, ref, reader): | ||
# don't recreate reader here as in inherited base test | ||
ts = reader.next() | ||
assert_timestep_almost_equal(ts, ref.second_frame, decimal=ref.prec) | ||
|
||
def test_frame_collect_all_same(self, reader): | ||
pass | ||
|
||
|
||
@pytest.mark.skipif(not HAS_IMDCLIENT, reason="IMDClient not isntalled") | ||
class TestStreamIteration: | ||
|
||
@pytest.fixture | ||
def port(self): | ||
return get_free_port() | ||
|
||
@pytest.fixture | ||
def universe(self): | ||
return mda.Universe(COORDINATES_TOPOLOGY, COORDINATES_H5MD) | ||
|
||
@pytest.fixture | ||
def imdsinfo(self): | ||
return create_default_imdsinfo_v3() | ||
|
||
@pytest.fixture | ||
def reader(self, universe, imdsinfo, port): | ||
server = InThreadIMDServer(universe.trajectory) | ||
server.set_imdsessioninfo(imdsinfo) | ||
server.handshake_sequence("localhost", port, first_frame=True) | ||
reader = IMDReader( | ||
f"imd://localhost:{port}", | ||
n_atoms=universe.trajectory.n_atoms, | ||
) | ||
server.send_frames(1, 5) | ||
|
||
yield reader | ||
server.cleanup() | ||
|
||
def test_iterate_step(self, reader, universe): | ||
i = 0 | ||
for ts in reader[::2]: | ||
assert ts.frame == i | ||
i += 2 | ||
|
||
def test_iterate_twice_sliced_raises_error(self, reader): | ||
for ts in reader[::2]: | ||
pass | ||
with pytest.raises(RuntimeError, match="Cannot reopen stream"): | ||
for ts in reader[::2]: | ||
pass | ||
|
||
def test_iterate_twice_all_raises_error(self, reader): | ||
for ts in reader: | ||
pass | ||
with pytest.raises(RuntimeError, match="Cannot reopen stream"): | ||
for ts in reader: | ||
pass | ||
|
||
def test_iterate_twice_fi_all_raises_error(self, reader): | ||
for ts in reader[:]: | ||
pass | ||
with pytest.raises(RuntimeError, match="Cannot reopen stream"): | ||
for ts in reader[:]: | ||
pass | ||
|
||
def test_index_stream_raises_error(self, reader): | ||
with pytest.raises(TypeError, match="Streamed trajectories must be"): | ||
reader[0] | ||
|
||
def test_iterate_backwards_raises_error(self, reader): | ||
with pytest.raises(ValueError, match="Cannot go backwards"): | ||
for ts in reader[::-1]: | ||
pass | ||
|
||
|
||
def test_iterate_start_stop_raises_error(self, reader): | ||
with pytest.raises(ValueError, match="Cannot expect a start index"): | ||
for ts in reader[1:3]: | ||
pass | ||
|
||
def test_subslice_fi_all_after_iteration_raises_error(self, reader): | ||
sliced_reader = reader[:] | ||
for ts in sliced_reader: | ||
pass | ||
sub_sliced_reader = sliced_reader[::1] | ||
with pytest.raises(RuntimeError): | ||
for ts in sub_sliced_reader: | ||
pass | ||
|
||
|
||
def test_timeseries_raises(self, reader): | ||
with pytest.raises( | ||
RuntimeError, match="cannot access timeseries for streamed trajectories" | ||
): | ||
reader.timeseries() | ||
|
||
@pytest.mark.skipif(not HAS_IMDCLIENT, reason="IMDClient not installed") | ||
def test_n_atoms_mismatch(): | ||
universe = mda.Universe(COORDINATES_TOPOLOGY, COORDINATES_H5MD) | ||
port = get_free_port() | ||
server = InThreadIMDServer(universe.trajectory) | ||
server.set_imdsessioninfo(create_default_imdsinfo_v3()) | ||
server.handshake_sequence("localhost", port, first_frame=True) | ||
with pytest.raises( | ||
EOFError, | ||
match="IMDProducer: Expected n_atoms value 6, got 5. Ensure you are using the correct topology file.", | ||
): | ||
IMDReader( | ||
f"imd://localhost:{port}", | ||
n_atoms=universe.trajectory.n_atoms + 1, | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's going to take time to get imdclient to a stage where the imdclient package does not actually affect MDAnalysis.
Is there a way that we could temporarily (for initial CI testing) install imdclient from a branch or tarball, e.g., in a
pip
section? Then we could fairly rapidly create a preliminary (unpublished) imdclient package without IMDReader.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By initial CI testing, do you mean "in this PR"?
There's a pip section just below, which should work if you put in the git location for pip install, but also you can just temporarily modify the CI script to do an additional pip install if it's for "testing within the PR itself".
If it's "after merge", this would require more discussion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just for right now to bootstrap the PR.
I don't want to merge without a solid conda-forge imdclient package in place.