nerdd-link 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/PKG-INFO +1 -1
  2. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/actions/predict_checkpoints_action.py +2 -2
  3. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/actions/register_module_action.py +14 -7
  4. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/actions/serialize_job_action.py +20 -16
  5. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/channels/channel.py +3 -6
  6. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/cli/initialize_system.py +4 -0
  7. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/cli/run_job_server.py +9 -1
  8. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/cli/run_prediction_server.py +4 -0
  9. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/cli/run_serialization_server.py +3 -9
  10. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/converters/__init__.py +1 -0
  11. nerdd_link-0.2.4/nerdd_link/converters/mol_to_image_converter.py +59 -0
  12. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/delegates/__init__.py +1 -0
  13. nerdd_link-0.2.4/nerdd_link/delegates/serialize_job_model.py +19 -0
  14. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/files/file_system.py +8 -0
  15. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/types/__init__.py +4 -1
  16. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link.egg-info/PKG-INFO +1 -1
  17. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link.egg-info/SOURCES.txt +2 -0
  18. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/pyproject.toml +2 -2
  19. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/LICENSE +0 -0
  20. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/README.md +0 -0
  21. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/__init__.py +0 -0
  22. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/actions/__init__.py +0 -0
  23. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/actions/action.py +0 -0
  24. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/actions/process_jobs_action.py +0 -0
  25. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/channels/__init__.py +0 -0
  26. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/channels/kafka_channel.py +0 -0
  27. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/channels/memory_channel.py +0 -0
  28. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/cli/__init__.py +0 -0
  29. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/converters/mol_pickle_converter.py +0 -0
  30. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/converters/pickle_converter.py +0 -0
  31. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/delegates/pickle_writer.py +0 -0
  32. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/delegates/read_checkpoint_model.py +0 -0
  33. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/delegates/read_pickle_step.py +0 -0
  34. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/delegates/split_and_merge_step.py +0 -0
  35. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/delegates/topic_writer.py +0 -0
  36. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/files/__init__.py +0 -0
  37. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/input/__init__.py +0 -0
  38. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/input/structure_json_reader.py +0 -0
  39. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/py.typed +0 -0
  40. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/tests/__init__.py +0 -0
  41. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/tests/async_step.py +0 -0
  42. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/tests/channels.py +0 -0
  43. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/tests/files.py +0 -0
  44. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/utils/__init__.py +0 -0
  45. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/utils/async_to_sync.py +0 -0
  46. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/utils/batched.py +0 -0
  47. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/utils/observable_list.py +0 -0
  48. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/utils/safetee.py +0 -0
  49. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link/version.py +0 -0
  50. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link.egg-info/dependency_links.txt +0 -0
  51. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link.egg-info/entry_points.txt +0 -0
  52. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link.egg-info/requires.txt +0 -0
  53. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/nerdd_link.egg-info/top_level.txt +0 -0
  54. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/setup.cfg +0 -0
  55. {nerdd_link-0.2.2 → nerdd_link-0.2.4}/tests/test_features.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nerdd-link
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Run a NERDD module as a service
5
5
  Author-email: Steffen Hirte <steffen.hirte@univie.ac.at>
6
6
  Maintainer-email: Steffen Hirte <steffen.hirte@univie.ac.at>
@@ -49,5 +49,5 @@ class PredictCheckpointsAction(Action[CheckpointMessage]):
49
49
  )
50
50
 
51
51
  def _get_group_name(self) -> str:
52
- model_name = self._model.__class__.__name__
53
- return model_name
52
+ model_id = self._model.get_config().id
53
+ return f"predict-checkpoints-{model_id}"
@@ -1,9 +1,10 @@
1
+ import json
1
2
  import logging
2
3
 
3
4
  from nerdd_module import Model
4
- from stringcase import spinalcase
5
5
 
6
6
  from ..channels import Channel
7
+ from ..files import FileSystem
7
8
  from ..types import ModuleMessage, SystemMessage
8
9
  from .action import Action
9
10
 
@@ -13,18 +14,24 @@ logger = logging.getLogger(__name__)
13
14
 
14
15
 
15
16
  class RegisterModuleAction(Action[SystemMessage]):
16
- def __init__(self, channel: Channel, model: Model):
17
+ def __init__(self, channel: Channel, model: Model, data_dir: str):
17
18
  super().__init__(channel.system_topic())
18
19
  # TODO: do this differently
19
20
  assert hasattr(model, "get_config")
20
21
  self._model = model
22
+ self._file_system = FileSystem(data_dir)
21
23
 
22
24
  async def _process_message(self, message: SystemMessage) -> None:
23
- # send the initialization message
24
25
  config = self._model.get_config()
25
- logger.info(f"Send registration message for module {config.name}")
26
- await self.channel.modules_topic().send(ModuleMessage(**config.model_dump()))
26
+ logger.info(f"Registering module with id {config.id}")
27
+
28
+ # save module as json to file
29
+ module_file = self._file_system.get_module_file_path(config.id)
30
+ json.dump(config.model_dump(), open(module_file, "w"))
31
+
32
+ # send the initialization message
33
+ await self.channel.modules_topic().send(ModuleMessage(id=config.id))
27
34
 
28
35
  def _get_group_name(self) -> str:
29
- model_name = spinalcase(self._model.__class__.__name__)
30
- return model_name
36
+ model_id = self._model.get_config().id
37
+ return f"register-module-{model_id}"
@@ -1,10 +1,10 @@
1
+ import json
1
2
  import logging
2
3
 
3
- from nerdd_module import Model, OutputStep
4
- from stringcase import spinalcase
4
+ from nerdd_module import OutputStep
5
5
 
6
6
  from ..channels import Channel
7
- from ..delegates import ReadPickleStep
7
+ from ..delegates import ReadPickleStep, SerializeJobModel
8
8
  from ..files import FileSystem
9
9
  from ..types import SerializationRequestMessage, SerializationResultMessage
10
10
  from .action import Action
@@ -16,13 +16,13 @@ logger = logging.getLogger(__name__)
16
16
 
17
17
 
18
18
  class SerializeJobAction(Action[SerializationRequestMessage]):
19
- def __init__(self, channel: Channel, model: Model, data_dir: str) -> None:
20
- super().__init__(channel.serialization_requests_topic(model))
21
- self._model = model
19
+ def __init__(self, channel: Channel, data_dir: str) -> None:
20
+ super().__init__(channel.serialization_requests_topic())
22
21
  self._file_system = FileSystem(data_dir)
23
22
 
24
23
  async def _process_message(self, message: SerializationRequestMessage) -> None:
25
24
  job_id = message.job_id
25
+ job_type = message.job_type
26
26
  params = message.params
27
27
  output_format = message.output_format
28
28
  logger.info(f"Write output for job {job_id} in format {output_format}")
@@ -31,15 +31,23 @@ class SerializeJobAction(Action[SerializationRequestMessage]):
31
31
  params.pop("output_file", None)
32
32
  params.pop("output_format", None)
33
33
 
34
+ # obtain output file
34
35
  output_file = self._file_system.get_output_file(job_id, output_format)
35
36
 
36
- # TODO: don't write the file if it exists
37
+ # get the configuration for the job_type
38
+ config_file = self._file_system.get_module_file_path(job_type)
39
+ config = json.load(open(config_file, "r"))
40
+
41
+ # create a fake model instance to get the postprocessing steps
42
+ model = SerializeJobModel(config)
43
+
44
+ steps = [
45
+ # read the result checkpoint files in the correct order
46
+ ReadPickleStep(self._file_system.iter_results_file_handles(job_id)),
47
+ # don't preprocess, don't do prediction, only post-process
48
+ *model._get_postprocessing_steps(output_format, output_file=output_file, **params),
49
+ ]
37
50
 
38
- read_pickle_step = ReadPickleStep(self._file_system.iter_results_file_handles(job_id))
39
- post_processing_steps = self._model._get_postprocessing_steps(
40
- output_format, output_file=output_file, **params
41
- )
42
- steps = [read_pickle_step, *post_processing_steps]
43
51
  output_step = steps[-1]
44
52
  assert isinstance(output_step, OutputStep), "The last step must be an OutputStep."
45
53
 
@@ -54,7 +62,3 @@ class SerializeJobAction(Action[SerializationRequestMessage]):
54
62
  await self.channel.serialization_results_topic().send(
55
63
  SerializationResultMessage(job_id=job_id, output_format=output_format)
56
64
  )
57
-
58
- def _get_group_name(self) -> str:
59
- model_name = spinalcase(self._model.__class__.__name__)
60
- return model_name
@@ -32,6 +32,7 @@ def get_job_type(job_type_or_model: Union[str, Model]) -> str:
32
32
  # * converting to spinal case, (e.g. "MyModel" -> "my-model")
33
33
  # * converting to lowercase (just to be sure) and
34
34
  # * removing all characters except dash and alphanumeric characters
35
+ # TODO: move to Module Id
35
36
  topic_name = spinalcase(model.name)
36
37
  topic_name = topic_name.lower()
37
38
  topic_name = "".join([c for c in topic_name if str.isalnum(c) or c == "-"])
@@ -133,12 +134,8 @@ class Channel(ABC):
133
134
  def result_checkpoints_topic(self) -> Topic[ResultCheckpointMessage]:
134
135
  return Topic[ResultCheckpointMessage](self, "result-checkpoints")
135
136
 
136
- def serialization_requests_topic(
137
- self, job_type_or_model: Union[str, Model]
138
- ) -> Topic[SerializationRequestMessage]:
139
- job_type = get_job_type(job_type_or_model)
140
- topic_name = f"{job_type}-serialization-requests"
141
- return Topic[SerializationRequestMessage](self, topic_name)
137
+ def serialization_requests_topic(self) -> Topic[SerializationRequestMessage]:
138
+ return Topic[SerializationRequestMessage](self, "serialization-requests")
142
139
 
143
140
  def serialization_results_topic(self) -> Topic[SerializationResultMessage]:
144
141
  return Topic[SerializationResultMessage](self, "serialization-results")
@@ -41,5 +41,9 @@ async def initialize_system(
41
41
  else:
42
42
  raise ValueError(f"Channel {channel} not supported.")
43
43
 
44
+ await channel_instance.start()
45
+
44
46
  logging.info("Sending the system initialization message...")
45
47
  await channel_instance.system_topic().send(SystemMessage())
48
+
49
+ await channel_instance.stop()
@@ -19,7 +19,11 @@ logger = logging.getLogger(__name__)
19
19
  default="kafka",
20
20
  help="Channel to use for communication with the model.",
21
21
  )
22
- @click.option("--broker-url", default="localhost:9092", help="Kafka broker to connect to.")
22
+ @click.option(
23
+ "--broker-url",
24
+ default="localhost:9092",
25
+ help="Broker url to connect to.",
26
+ )
23
27
  @click.option(
24
28
  "--max-num-molecules",
25
29
  default=10_000,
@@ -86,6 +90,8 @@ async def run_job_server(
86
90
  else:
87
91
  raise ValueError(f"Channel {channel} not supported.")
88
92
 
93
+ await channel_instance.start()
94
+
89
95
  action = ProcessJobsAction(
90
96
  channel_instance,
91
97
  checkpoint_size,
@@ -105,3 +111,5 @@ async def run_job_server(
105
111
  logger.info("Shutting down server")
106
112
  task.cancel()
107
113
  await task
114
+
115
+ await channel_instance.stop()
@@ -53,6 +53,8 @@ async def run_prediction_server(
53
53
  else:
54
54
  raise ValueError(f"Channel {channel} not supported.")
55
55
 
56
+ await channel_instance.start()
57
+
56
58
  # import the model class
57
59
  package_name, class_name = model_name.rsplit(".", 1)
58
60
  package = import_module(package_name)
@@ -79,3 +81,5 @@ async def run_prediction_server(
79
81
  for task in tasks:
80
82
  task.cancel()
81
83
  await asyncio.gather(*tasks, return_exceptions=True)
84
+
85
+ await channel_instance.stop()
@@ -1,6 +1,5 @@
1
1
  import asyncio
2
2
  import logging
3
- from importlib import import_module
4
3
  from typing import List
5
4
 
6
5
  import rich_click as click
@@ -15,7 +14,6 @@ logger = logging.getLogger(__name__)
15
14
 
16
15
 
17
16
  @click.command(context_settings={"show_default": True})
18
- @click.argument("model-name")
19
17
  @click.option(
20
18
  "--channel",
21
19
  type=click.Choice(["kafka"], case_sensitive=False),
@@ -40,7 +38,6 @@ async def run_serialization_server(
40
38
  channel: str,
41
39
  broker_url: str,
42
40
  # options
43
- model_name: str,
44
41
  data_dir: str,
45
42
  # log level
46
43
  log_level: str,
@@ -53,15 +50,10 @@ async def run_serialization_server(
53
50
  else:
54
51
  raise ValueError(f"Channel {channel} not supported.")
55
52
 
56
- # import the model class
57
- package_name, class_name = model_name.rsplit(".", 1)
58
- package = import_module(package_name)
59
- Model = getattr(package, class_name)
60
- model = Model()
53
+ await channel_instance.start()
61
54
 
62
55
  serialize_job = SerializeJobAction(
63
56
  channel=channel_instance,
64
- model=model,
65
57
  data_dir=data_dir,
66
58
  )
67
59
 
@@ -77,3 +69,5 @@ async def run_serialization_server(
77
69
  for task in tasks:
78
70
  task.cancel()
79
71
  await asyncio.gather(*tasks, return_exceptions=True)
72
+
73
+ await channel_instance.stop()
@@ -1,2 +1,3 @@
1
1
  from .mol_pickle_converter import *
2
+ from .mol_to_image_converter import *
2
3
  from .pickle_converter import *
@@ -0,0 +1,59 @@
1
+ from typing import Any
2
+ from xml.dom import minidom
3
+
4
+ from nerdd_module import Converter, ConverterConfig
5
+ from rdkit.Chem import Mol
6
+ from rdkit.Chem.Draw import MolDraw2DSVG
7
+
8
+ __all__ = ["MolToImageConverter"]
9
+
10
+ default_width = 300
11
+ default_height = 180
12
+
13
+
14
+ class MolToImageConverter(Converter):
15
+ def _convert(self, input: Any, context: dict) -> Any:
16
+ width = self.result_property.image_width
17
+ height = self.result_property.image_height
18
+
19
+ if width is None:
20
+ width = default_width
21
+ if height is None:
22
+ height = default_height
23
+
24
+ mol = input
25
+ if mol is None:
26
+ return None
27
+
28
+ assert isinstance(mol, Mol), f"Expected RDKit Mol object, but got {type(mol)}"
29
+
30
+ svg = MolDraw2DSVG(width, height)
31
+
32
+ # remove background
33
+ opts = svg.drawOptions()
34
+ opts.clearBackground = False
35
+
36
+ # add highlight circles around atoms during drawing
37
+ # (we will remove them later in post processing)
38
+ atoms = range(mol.GetNumAtoms())
39
+ colors = [[(0.8, 1, 1)]] * mol.GetNumAtoms()
40
+ radii = [0.5] * mol.GetNumAtoms()
41
+ atom_highlight = dict(zip(atoms, colors))
42
+ atom_radii = dict(zip(atoms, radii))
43
+ svg.DrawMoleculeWithHighlights(mol, "", atom_highlight, {}, atom_radii, [])
44
+ svg.FinishDrawing()
45
+
46
+ # post process SVG
47
+ xml = svg.GetDrawingText()
48
+ tree = minidom.parseString(xml)
49
+ root = tree.getElementsByTagName("svg")[0]
50
+
51
+ # make highlight circles invisible
52
+ for ellipse in root.getElementsByTagName("ellipse"):
53
+ ellipse.setAttribute("style", "opacity:0")
54
+
55
+ xml = tree.toxml()
56
+
57
+ return xml
58
+
59
+ config = ConverterConfig(data_types="mol", output_formats="json")
@@ -1,4 +1,5 @@
1
1
  from .pickle_writer import *
2
2
  from .read_checkpoint_model import *
3
3
  from .read_pickle_step import *
4
+ from .serialize_job_model import *
4
5
  from .topic_writer import *
@@ -0,0 +1,19 @@
1
+ from typing import List
2
+
3
+ from nerdd_module import SimpleModel
4
+ from nerdd_module.config import Configuration, DictConfiguration
5
+ from rdkit.Chem import Mol
6
+
7
+ __all__ = ["SerializeJobModel"]
8
+
9
+
10
+ class SerializeJobModel(SimpleModel):
11
+ def __init__(self, config: dict) -> None:
12
+ super().__init__()
13
+ self._config = config
14
+
15
+ def _get_config(self) -> Configuration:
16
+ return DictConfiguration(self._config)
17
+
18
+ def _predict_mols(self, mols: List[Mol]) -> List[dict]:
19
+ return []
@@ -16,6 +16,11 @@ class FileSystem:
16
16
  #
17
17
  # DIRECTORIES
18
18
  #
19
+ def get_modules_dir(self) -> str:
20
+ result = os.path.join(self.root_path, "modules")
21
+ os.makedirs(result, exist_ok=True)
22
+ return result
23
+
19
24
  def get_sources_dir(self) -> str:
20
25
  result = os.path.join(self.root_path, "sources")
21
26
  os.makedirs(result, exist_ok=True)
@@ -49,6 +54,9 @@ class FileSystem:
49
54
  #
50
55
  # FILES
51
56
  #
57
+ def get_module_file_path(self, module_id: str) -> str:
58
+ return os.path.join(self.get_modules_dir(), module_id)
59
+
52
60
  def get_source_file_path(self, source_id: str) -> str:
53
61
  return os.path.join(self.get_sources_dir(), source_id)
54
62
 
@@ -20,7 +20,7 @@ class Message(BaseModel):
20
20
 
21
21
 
22
22
  class ModuleMessage(Message):
23
- name: str
23
+ id: str
24
24
 
25
25
 
26
26
  class CheckpointMessage(Message):
@@ -43,6 +43,7 @@ class JobMessage(Message):
43
43
 
44
44
  class SerializationRequestMessage(Message):
45
45
  job_id: str
46
+ job_type: str
46
47
  params: Dict[str, Any]
47
48
  output_format: str
48
49
 
@@ -55,6 +56,8 @@ class SerializationResultMessage(Message):
55
56
  class ResultMessage(Message):
56
57
  job_id: str
57
58
 
59
+ model_config = ConfigDict(extra="allow")
60
+
58
61
 
59
62
  class LogMessage(Message):
60
63
  job_id: str
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nerdd-link
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Run a NERDD module as a service
5
5
  Author-email: Steffen Hirte <steffen.hirte@univie.ac.at>
6
6
  Maintainer-email: Steffen Hirte <steffen.hirte@univie.ac.at>
@@ -27,11 +27,13 @@ nerdd_link/cli/run_prediction_server.py
27
27
  nerdd_link/cli/run_serialization_server.py
28
28
  nerdd_link/converters/__init__.py
29
29
  nerdd_link/converters/mol_pickle_converter.py
30
+ nerdd_link/converters/mol_to_image_converter.py
30
31
  nerdd_link/converters/pickle_converter.py
31
32
  nerdd_link/delegates/__init__.py
32
33
  nerdd_link/delegates/pickle_writer.py
33
34
  nerdd_link/delegates/read_checkpoint_model.py
34
35
  nerdd_link/delegates/read_pickle_step.py
36
+ nerdd_link/delegates/serialize_job_model.py
35
37
  nerdd_link/delegates/split_and_merge_step.py
36
38
  nerdd_link/delegates/topic_writer.py
37
39
  nerdd_link/files/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nerdd-link"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Run a NERDD module as a service"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -86,7 +86,7 @@ nerdd_link = ["py.typed"]
86
86
 
87
87
  [tool.pytest.ini_options]
88
88
  log_cli = 1
89
- log_cli_level = "INFO"
89
+ log_cli_level = "ERROR"
90
90
  addopts = "-x --cov-report term --cov=nerdd_link"
91
91
 
92
92
  [tool.pytest-watcher]
File without changes
File without changes
File without changes