bigraph-schema 1.4.2__tar.gz → 1.4.3__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 (52) hide show
  1. {bigraph_schema-1.4.2/bigraph_schema.egg-info → bigraph_schema-1.4.3}/PKG-INFO +1 -1
  2. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/__init__.py +1 -0
  3. bigraph_schema-1.4.3/bigraph_schema/contract.py +172 -0
  4. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/core.py +24 -7
  5. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/edge.py +25 -0
  6. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/apply.py +0 -28
  7. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/serialize.py +43 -7
  8. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/package/discover.py +53 -1
  9. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3/bigraph_schema.egg-info}/PKG-INFO +1 -1
  10. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema.egg-info/SOURCES.txt +4 -1
  11. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/pyproject.toml +1 -1
  12. bigraph_schema-1.4.3/tests/test_multi_package_dist_discovery.py +39 -0
  13. bigraph_schema-1.4.3/tests/test_process_contract.py +57 -0
  14. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/AUTHORS.md +0 -0
  15. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/LICENSE +0 -0
  16. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/README.md +0 -0
  17. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/assembly.py +0 -0
  18. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/calculi.py +0 -0
  19. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/json_codec.py +0 -0
  20. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/__init__.py +0 -0
  21. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/bundle.py +0 -0
  22. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/check.py +0 -0
  23. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/coerce.py +0 -0
  24. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/default.py +0 -0
  25. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/derive.py +0 -0
  26. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/diff.py +0 -0
  27. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/divide.py +0 -0
  28. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/events.py +0 -0
  29. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/generalize.py +0 -0
  30. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/handle_parameters.py +0 -0
  31. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/infer.py +0 -0
  32. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/is_empty.py +0 -0
  33. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/jump.py +0 -0
  34. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/merge.py +0 -0
  35. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/patch.py +0 -0
  36. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/realize.py +0 -0
  37. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/reconcile.py +0 -0
  38. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/resolve.py +0 -0
  39. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/select.py +0 -0
  40. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/transform.py +0 -0
  41. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/validate.py +0 -0
  42. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/walk.py +0 -0
  43. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/package/__init__.py +0 -0
  44. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/parse.py +0 -0
  45. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/protocols.py +0 -0
  46. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/schema.py +0 -0
  47. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/units.py +0 -0
  48. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema.egg-info/dependency_links.txt +0 -0
  49. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema.egg-info/requires.txt +0 -0
  50. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema.egg-info/top_level.txt +0 -0
  51. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/setup.cfg +0 -0
  52. {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bigraph-schema
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: A serializable type schema for compositional systems biology
5
5
  Author: Eran Agmon, Ryan Spangler
6
6
  Requires-Python: >=3.9
@@ -1,4 +1,5 @@
1
1
  from bigraph_schema.edge import Edge
2
+ from bigraph_schema.contract import ProcessContract, resolve_contract
2
3
  from bigraph_schema.protocols import local_lookup_module
3
4
 
4
5
  from bigraph_schema.schema import BASE_TYPES, resolve_path, deep_merge, make_default
@@ -0,0 +1,172 @@
1
+ """
2
+ ========
3
+ Contract
4
+ ========
5
+
6
+ A ``ProcessContract`` is a structured, mathematically-legible *superset* of the
7
+ existing :attr:`bigraph_schema.edge.Edge.description` convention. ``description``
8
+ is a flat markdown/LaTeX string that already states a process's governing
9
+ equations; a ``ProcessContract`` adds explicit per-port / per-config / per-symbol
10
+ semantics around that same math.
11
+
12
+ The two are wired so the existing ``description`` is **reused, never rewritten**:
13
+ when a contract omits ``summary`` / ``math`` / ``description`` those fields are
14
+ derived by parsing the flat ``description`` string (see
15
+ :meth:`ProcessContract.from_description` and
16
+ :meth:`ProcessContract.merged_with_description`). Authored ``inputs`` /
17
+ ``outputs`` / ``config`` / ``symbols`` are always left untouched.
18
+
19
+ Everything here is additive: a process that declares no contract resolves to a
20
+ description-derived (or docstring-derived) one, or to ``None`` — it never raises
21
+ and never changes runtime behavior.
22
+ """
23
+
24
+ import inspect
25
+ import copy
26
+ import re
27
+ from dataclasses import dataclass, field, asdict
28
+
29
+
30
+ # Lines that read as governing equations rather than prose. Matched by any of
31
+ # the canonical relation / operator markers, or by a probability-distribution
32
+ # name (so a line like ``x ~ Multinomial(...)`` is captured as math).
33
+ _DIST_NAMES = (
34
+ r"Multinomial|Binomial|Poisson|Normal|Gaussian|Bernoulli|Uniform|"
35
+ r"Exponential|Gamma|Beta|Dirichlet|Categorical|Geometric|Lognormal|"
36
+ r"LogNormal|Hypergeometric|NegativeBinomial|Weibull|Chi|StudentT"
37
+ )
38
+ _MATH_RE = re.compile(r"[=~≈←≥≤∑∏]|\b(?:" + _DIST_NAMES + r")\b")
39
+
40
+
41
+ @dataclass
42
+ class ProcessContract:
43
+ """A structured superset of ``Edge.description``.
44
+
45
+ All mutable fields use ``default_factory`` so instances never share state.
46
+ """
47
+
48
+ summary: str = ""
49
+ description: str = ""
50
+ inputs: dict = field(default_factory=dict)
51
+ outputs: dict = field(default_factory=dict)
52
+ config: dict = field(default_factory=dict)
53
+ math: list = field(default_factory=list)
54
+ symbols: dict = field(default_factory=dict)
55
+ assumptions: list = field(default_factory=list)
56
+ references: list = field(default_factory=list)
57
+
58
+ def to_dict(self):
59
+ """Return a JSON-safe plain-``dict`` representation."""
60
+ return asdict(self)
61
+
62
+ @classmethod
63
+ def from_description(cls, text):
64
+ """Parse a flat ``description`` string into a contract.
65
+
66
+ - the first non-empty line becomes ``summary``
67
+ - subsequent lines carrying an equation marker (see ``_MATH_RE``) become
68
+ ``math`` entries
69
+ - the remaining non-empty lines are joined into ``description``
70
+
71
+ Returns ``None`` for empty / ``None`` input.
72
+ """
73
+ if text is None:
74
+ return None
75
+ text = str(text)
76
+ if not text.strip():
77
+ return None
78
+
79
+ summary = ""
80
+ math = []
81
+ rest = []
82
+ seen_summary = False
83
+ for line in text.splitlines():
84
+ stripped = line.strip()
85
+ if not stripped:
86
+ continue
87
+ if not seen_summary:
88
+ summary = stripped
89
+ seen_summary = True
90
+ continue
91
+ if _MATH_RE.search(stripped):
92
+ math.append(stripped)
93
+ else:
94
+ rest.append(stripped)
95
+
96
+ return cls(
97
+ summary=summary,
98
+ description="\n".join(rest),
99
+ math=math)
100
+
101
+ def merged_with_description(self, desc):
102
+ """Return a copy with only EMPTY ``summary`` / ``math`` / ``description``
103
+ filled from parsing ``desc``.
104
+
105
+ Authored ``inputs`` / ``outputs`` / ``config`` / ``symbols`` /
106
+ ``assumptions`` / ``references`` are preserved untouched.
107
+ """
108
+ merged = copy.deepcopy(self)
109
+ parsed = ProcessContract.from_description(desc)
110
+ if parsed is not None:
111
+ if not merged.summary:
112
+ merged.summary = parsed.summary
113
+ if not merged.math:
114
+ merged.math = list(parsed.math)
115
+ if not merged.description:
116
+ merged.description = parsed.description
117
+ return merged
118
+
119
+
120
+ def _as_contract(declared):
121
+ """Coerce a declared ``contract`` attribute into a ``ProcessContract``.
122
+
123
+ Accepts a ``ProcessContract`` (returned as-is) or a plain ``dict`` (used as
124
+ kwargs). Any other type yields ``None`` so resolution falls through.
125
+ """
126
+ if isinstance(declared, ProcessContract):
127
+ return declared
128
+ if isinstance(declared, dict):
129
+ try:
130
+ return ProcessContract(**declared)
131
+ except TypeError:
132
+ return None
133
+ return None
134
+
135
+
136
+ def resolve_contract(instance):
137
+ """Resolve the ``ProcessContract`` for a process/step ``instance``.
138
+
139
+ Resolution order (first that yields a contract wins):
140
+
141
+ 1. a declared ``contract`` attribute (``ProcessContract`` or ``dict``),
142
+ merged with the object's ``description`` (authored rows preserved);
143
+ 2. a description-derived contract from the object's ``description``;
144
+ 3. a docstring-derived contract from the class docstring.
145
+
146
+ Never raises: odd input (``None``, a bare ``object()``, anything without a
147
+ class docstring of its own) returns ``None``.
148
+ """
149
+ if instance is None:
150
+ return None
151
+ try:
152
+ declared = getattr(instance, "contract", None)
153
+ desc = getattr(instance, "description", None)
154
+
155
+ contract = _as_contract(declared)
156
+ if contract is not None:
157
+ return contract.merged_with_description(desc or "")
158
+
159
+ if desc:
160
+ derived = ProcessContract.from_description(desc)
161
+ if derived is not None:
162
+ return derived
163
+
164
+ # Docstring fallback — but ignore a docstring merely inherited from
165
+ # ``object`` (so a bare ``object()`` resolves to None, while a class
166
+ # with its own docstring resolves).
167
+ doc = inspect.getdoc(type(instance))
168
+ if doc and doc != inspect.getdoc(object):
169
+ return ProcessContract.from_description(doc)
170
+ return None
171
+ except Exception:
172
+ return None
@@ -104,8 +104,8 @@ def schema_keys(schema):
104
104
  def append_link_path(schema, link_path):
105
105
  if isinstance(schema, Node):
106
106
  if not hasattr(schema, '_link_path'):
107
- schema.link_path = []
108
- schema.link_path.append(link_path)
107
+ schema._link_path = []
108
+ schema._link_path.append(link_path)
109
109
  else:
110
110
  if not '_link_path' in schema:
111
111
  schema['_link_path'] = []
@@ -135,10 +135,16 @@ class Core:
135
135
  for key, values in importlib.metadata.packages_distributions().items()
136
136
  for value in values}
137
137
 
138
- self.distributions_packages = {
139
- value: key
140
- for key, values in self.packages_distributions.items()
141
- for value in values}
138
+ # dist -> LIST of its import packages. A single distribution can ship
139
+ # more than one import package (e.g. a real package plus a back-compat
140
+ # shim), so this must AGGREGATE, not last-wins-collapse to a single
141
+ # package — otherwise an empty shim can shadow the real package and its
142
+ # edges/types/visualizations are never discovered (bigraph-schema
143
+ # discover walks every package listed here).
144
+ self.distributions_packages = {}
145
+ for package, dists in self.packages_distributions.items():
146
+ for dist in dists:
147
+ self.distributions_packages.setdefault(dist, []).append(package)
142
148
 
143
149
  self.registry = {}
144
150
  self.link_registry = {}
@@ -188,6 +194,17 @@ class Core:
188
194
  self.registry[key] = found
189
195
  # Invalidate access cache since type registry changed
190
196
  self._access_cache.pop(key, None)
197
+ # Record the reverse class -> name mapping so serialize.render() can emit
198
+ # this type's NAME (not a Python class repr) when a port declares it as a
199
+ # type OBJECT (e.g. a process whose inputs() returns InPlaceDict()).
200
+ try:
201
+ from bigraph_schema.methods.serialize import register_type_name
202
+ from bigraph_schema.schema import Node
203
+ cls = found if isinstance(found, type) else type(found)
204
+ if isinstance(cls, type) and issubclass(cls, Node):
205
+ register_type_name(cls, key)
206
+ except Exception:
207
+ pass
191
208
 
192
209
  def register_types(self, types):
193
210
  """Bulk register multiple type keys into the operation registry."""
@@ -200,7 +217,7 @@ class Core:
200
217
  self.registry[key] = self.resolve(self.registry[key], data)
201
218
 
202
219
  def register_link(self, key, link):
203
- if key in self.registry:
220
+ if key in self.link_registry:
204
221
  self.update_link(key, link)
205
222
  else:
206
223
  self.link_registry[key] = link
@@ -8,6 +8,8 @@ Base class for all edges in the bigraph schema.
8
8
 
9
9
  import inspect
10
10
 
11
+ from bigraph_schema.contract import resolve_contract
12
+
11
13
 
12
14
  def default_wires(schema):
13
15
  """
@@ -37,6 +39,14 @@ class Edge:
37
39
  # to the class docstring when this is left empty.
38
40
  description = ''
39
41
 
42
+ # Structured superset of `description` (a `ProcessContract`, or a dict of
43
+ # its fields). Left `None` on the base class — resolves through normal MRO,
44
+ # so leaving it unset never changes runtime behavior. Subclasses may set it
45
+ # to advertise per-port / per-config / per-symbol semantics; when unset the
46
+ # contract is derived from `description` (or the docstring). Read it via the
47
+ # resolver method below or the module fn `contract.resolve_contract`.
48
+ contract = None
49
+
40
50
  def __init__(self, config=None, core=None):
41
51
  """
42
52
  Initialize the edge with a config and simulation core.
@@ -156,4 +166,19 @@ class Edge:
156
166
  """
157
167
  return self.description or inspect.getdoc(type(self)) or ''
158
168
 
169
+ def describe_contract(self):
170
+ """
171
+ Return this edge's resolved :class:`~bigraph_schema.contract.ProcessContract`.
172
+
173
+ A declared ``contract`` (class attribute) wins, merged with
174
+ ``description``; otherwise the contract is derived from ``description``
175
+ or the class docstring. Returns ``None`` when there is nothing to
176
+ surface. Never raises.
177
+
178
+ (Named ``describe_contract`` rather than ``contract`` because ``contract``
179
+ is the data slot subclasses populate — a same-named method would shadow
180
+ it and break declaration.)
181
+ """
182
+ return resolve_contract(self)
183
+
159
184
 
@@ -1,6 +1,5 @@
1
1
  from plum import dispatch
2
2
  import numpy as np
3
- import os
4
3
 
5
4
  from bigraph_schema.methods.check import check
6
5
  from bigraph_schema.methods.default import default
@@ -351,35 +350,8 @@ def _handle_divide_sentinel(value_schema, state, update, path):
351
350
  # parity with the per-gen Nextflow path which generates both
352
351
  # daughters and only loads daughter_state_0.json forward.
353
352
  mother_state = state[mother]
354
- # DEBUG: dump schema seen by divide
355
- if os.environ.get('VECOLI_DEBUG_DIVIDE'):
356
- import sys as _sys
357
- print(f'[divide-debug] path={path} mother={mother!r}', file=_sys.stderr, flush=True)
358
- print(f'[divide-debug] value_schema type={type(value_schema).__name__}', file=_sys.stderr, flush=True)
359
- print(f'[divide-debug] value_schema repr={value_schema!r}', file=_sys.stderr, flush=True)
360
- print(f'[divide-debug] mother_state keys={list(mother_state.keys()) if isinstance(mother_state, dict) else type(mother_state).__name__}', file=_sys.stderr, flush=True)
361
- if isinstance(mother_state, dict) and 'bulk' in mother_state:
362
- bulk_state = mother_state['bulk']
363
- print(f'[divide-debug] mother_state[bulk] type={type(bulk_state).__name__}', file=_sys.stderr, flush=True)
364
- sub_schema = getattr(value_schema, 'bulk', None)
365
- print(f'[divide-debug] value_schema.bulk attr={type(sub_schema).__name__ if sub_schema is not None else "NONE"}', file=_sys.stderr, flush=True)
366
- if sub_schema is None and isinstance(value_schema, dict):
367
- sub_schema = value_schema.get('bulk')
368
- print(f'[divide-debug] value_schema[bulk] dict-lookup={type(sub_schema).__name__ if sub_schema is not None else "NONE"}', file=_sys.stderr, flush=True)
369
353
  baselines = _divide_state(value_schema, mother_state,
370
354
  context=mother_state, path=())
371
- if os.environ.get('VECOLI_DEBUG_DIVIDE'):
372
- import sys as _sys
373
- if isinstance(baselines[0], dict) and 'bulk' in baselines[0]:
374
- import numpy as np
375
- ba = baselines[0]['bulk']
376
- ma = mother_state['bulk']
377
- try:
378
- if hasattr(ba, 'dtype') and ba.dtype.names and 'count' in ba.dtype.names:
379
- print(f'[divide-debug] mother bulk count_sum={int(ma["count"].sum())}', file=_sys.stderr, flush=True)
380
- print(f'[divide-debug] daughter_a bulk count_sum={int(ba["count"].sum())} (expect ~half)', file=_sys.stderr, flush=True)
381
- except Exception as e:
382
- print(f'[divide-debug] err inspecting baseline: {e}', file=_sys.stderr, flush=True)
383
355
 
384
356
  # Remove the mother and install the requested daughters.
385
357
  del state[mother]
@@ -486,17 +486,42 @@ def render(schema: Node, defaults=False):
486
486
  return wrap_default(schema, subrender) if defaults else subrender
487
487
 
488
488
 
489
- # Reverse lookup: Node subclass -> registered type name
489
+ # Reverse lookup: Node subclass -> registered type name. Seeded from BASE_TYPES,
490
+ # and extended by the core as it registers further types (e.g. workspace types
491
+ # like `inplace_dict`) via `register_type_name` — so render() can emit a name for
492
+ # any registered type, not only the base set.
490
493
  _TYPE_NAME_CACHE = {}
494
+ _BASE_SEEDED = False
495
+
496
+
497
+ def _seed_base_types():
498
+ global _BASE_SEEDED
499
+ if _BASE_SEEDED:
500
+ return
501
+ from bigraph_schema.schema import BASE_TYPES
502
+ for name, type_cls in BASE_TYPES.items():
503
+ if isinstance(type_cls, type):
504
+ _TYPE_NAME_CACHE.setdefault(type_cls, name)
505
+ _BASE_SEEDED = True
506
+
507
+
508
+ def register_type_name(cls, name):
509
+ """Record a ``Node`` subclass → its registered type name so ``render`` can
510
+ emit the name for types registered beyond BASE_TYPES. Prefer an explicit
511
+ registered name (e.g. ``inplace_dict``) over the class's own CamelCase name
512
+ (types are commonly auto-registered under ``__name__`` too, so first-wins
513
+ would otherwise keep ``InPlaceDict``)."""
514
+ if not isinstance(cls, type):
515
+ return
516
+ _seed_base_types()
517
+ existing = _TYPE_NAME_CACHE.get(cls)
518
+ if existing is None or (existing == cls.__name__ and name != cls.__name__):
519
+ _TYPE_NAME_CACHE[cls] = name
520
+
491
521
 
492
522
  def _node_type_name(cls):
493
523
  """Get the registered type name for a Node subclass, if any."""
494
- if not _TYPE_NAME_CACHE:
495
- # Build cache from BASE_TYPES
496
- from bigraph_schema.schema import BASE_TYPES
497
- for name, type_cls in BASE_TYPES.items():
498
- if isinstance(type_cls, type):
499
- _TYPE_NAME_CACHE[type_cls] = name
524
+ _seed_base_types()
500
525
  return _TYPE_NAME_CACHE.get(cls)
501
526
 
502
527
  @dispatch
@@ -726,6 +751,17 @@ def serialize(schema: Link, state):
726
751
  if state.get('_triggers'):
727
752
  encode['_triggers'] = state.get('_triggers')
728
753
 
754
+ # Additive: surface the resolved ProcessContract when the live instance
755
+ # supports one. Guarded so a process without contract support (or any odd
756
+ # state) never breaks Link serialization.
757
+ try:
758
+ from bigraph_schema.contract import resolve_contract
759
+ contract = resolve_contract(instance) if instance is not None else None
760
+ if contract is not None:
761
+ encode['_contract'] = contract.to_dict()
762
+ except Exception:
763
+ pass
764
+
729
765
  return encode
730
766
 
731
767
 
@@ -8,6 +8,19 @@ from bigraph_schema import Edge
8
8
  from bigraph_schema.schema import Node
9
9
 
10
10
 
11
+ # Submodule / subpackage names that core discovery must never import.
12
+ # Test scaffolding routinely pulls in heavy, test-only optional
13
+ # dependencies (e.g. fastapi/starlette ``TestClient`` needs httpx) and
14
+ # registers no Processes/Steps/Types worth discovering. Importing it can
15
+ # raise arbitrary exceptions and abort the whole walk, so skip it up front.
16
+ SKIP_SUBMODULES = frozenset({"tests", "testing"})
17
+
18
+
19
+ def _should_skip_submodule(subname: str) -> bool:
20
+ """True for test scaffolding that must be excluded from the walk."""
21
+ return subname in SKIP_SUBMODULES or subname.startswith("test_")
22
+
23
+
11
24
  def find_edges(mapping, module_name=None):
12
25
  discovered = []
13
26
  for _, cls in mapping:
@@ -75,7 +88,23 @@ def recursive_dynamic_import(
75
88
  visited.add(adjusted)
76
89
 
77
90
  if isinstance(module, str):
78
- adjusted = core.distributions_packages.get(module, module)
91
+ # A dist name can map to MORE THAN ONE import package (a real package
92
+ # plus a back-compat shim shipped in the same distribution). Walk EVERY
93
+ # package for the dist — otherwise an empty shim shadows the real
94
+ # package and its edges/types/visualizations are never discovered.
95
+ dist_packages = core.distributions_packages.get(module)
96
+ if dist_packages is not None:
97
+ for package in dist_packages:
98
+ if package in visited:
99
+ continue
100
+ core, pkg_edges, pkg_types, visited = recursive_dynamic_import(
101
+ core, package, visited=visited)
102
+ edges.extend(pkg_edges)
103
+ types.extend(pkg_types)
104
+ return core, edges, types, visited
105
+
106
+ # Not a known dist name -> treat as an import-package/module name.
107
+ adjusted = module
79
108
  if adjusted in visited:
80
109
  return core, edges, types, visited
81
110
  visited.add(adjusted)
@@ -83,6 +112,12 @@ def recursive_dynamic_import(
83
112
  try:
84
113
  module = importlib.import_module(adjusted)
85
114
 
115
+ except (KeyboardInterrupt, SystemExit):
116
+ # Never swallow interpreter-control signals: a Ctrl-C or an
117
+ # intentional sys.exit() raised at import time must propagate
118
+ # so discovery can't trap a deliberate interpreter exit.
119
+ raise
120
+
86
121
  except ImportError as e:
87
122
  # Catch both ModuleNotFoundError (the target itself is missing)
88
123
  # and ImportError (the target exists but a dep inside it failed
@@ -97,6 +132,20 @@ def recursive_dynamic_import(
97
132
  print(f"module `{adjusted}` not found during dynamic import")
98
133
  return core, edges, types, visited
99
134
 
135
+ except Exception as e:
136
+ # A submodule can fail at import time with an exception that is
137
+ # NOT an ImportError — e.g. starlette raising RuntimeError when
138
+ # an optional test dependency (httpx) is absent, or an OSError
139
+ # from a module that touches the filesystem at import. One broken
140
+ # or optional submodule must never abort the entire package walk,
141
+ # so log a clear warning and skip it. KeyboardInterrupt/SystemExit
142
+ # are BaseException subclasses and are intentionally not caught
143
+ # here (handled above), so Ctrl-C still works.
144
+ print(
145
+ f"skipping `{adjusted}` "
146
+ f"({type(e).__name__} raised at import: {e})")
147
+ return core, edges, types, visited
148
+
100
149
  # Allow module to register types into core
101
150
  if hasattr(module, "register_types"):
102
151
  core = module.register_types(core)
@@ -108,6 +157,9 @@ def recursive_dynamic_import(
108
157
  # Recurse into submodules if this is a package
109
158
  if hasattr(module, "__path__"):
110
159
  for _, subname, _ in pkgutil.iter_modules(module.__path__):
160
+ # Never descend into test scaffolding (test_*, tests, testing).
161
+ if _should_skip_submodule(subname):
162
+ continue
111
163
  submod = f"{adjusted}.{subname}"
112
164
  core, sub_edges, sub_types, visited = recursive_dynamic_import(
113
165
  core, submod, visited=visited)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bigraph-schema
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: A serializable type schema for compositional systems biology
5
5
  Author: Eran Agmon, Ryan Spangler
6
6
  Requires-Python: >=3.9
@@ -6,6 +6,7 @@ setup.py
6
6
  bigraph_schema/__init__.py
7
7
  bigraph_schema/assembly.py
8
8
  bigraph_schema/calculi.py
9
+ bigraph_schema/contract.py
9
10
  bigraph_schema/core.py
10
11
  bigraph_schema/edge.py
11
12
  bigraph_schema/json_codec.py
@@ -44,4 +45,6 @@ bigraph_schema/methods/transform.py
44
45
  bigraph_schema/methods/validate.py
45
46
  bigraph_schema/methods/walk.py
46
47
  bigraph_schema/package/__init__.py
47
- bigraph_schema/package/discover.py
48
+ bigraph_schema/package/discover.py
49
+ tests/test_multi_package_dist_discovery.py
50
+ tests/test_process_contract.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bigraph-schema"
7
- version = "1.4.2"
7
+ version = "1.4.3"
8
8
  description = "A serializable type schema for compositional systems biology"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,39 @@
1
+ """A distribution can ship more than one import package (e.g. a real package
2
+ plus a back-compat shim). Discovery must AGGREGATE all packages for a dist —
3
+ not last-wins-collapse to a single one — else an empty shim can shadow the real
4
+ package and its edges/types/visualizations are never discovered.
5
+
6
+ Regression for the pbg-superpowers 0.16.0 case: the dist ships both
7
+ ``viva_superpowers`` (real) and ``pbg_superpowers`` (shim); last-wins could
8
+ resolve the dist to the empty shim, so the real package's Demo* viz classes
9
+ weren't discovered.
10
+ """
11
+ import importlib.metadata
12
+
13
+ from bigraph_schema import Core
14
+
15
+
16
+ def test_multi_package_dist_aggregates_not_last_wins(monkeypatch):
17
+ # A dist ("multi-dist") that ships TWO import packages.
18
+ fake = {
19
+ "real_pkg": ["multi-dist"],
20
+ "shim_pkg": ["multi-dist"],
21
+ "bigraph_schema": ["bigraph-schema"],
22
+ }
23
+ monkeypatch.setattr(
24
+ importlib.metadata, "packages_distributions", lambda: fake)
25
+
26
+ core = Core({})
27
+
28
+ packages = core.distributions_packages["multi-dist"]
29
+ assert isinstance(packages, list)
30
+ # BOTH packages are present — not just the last-wins one.
31
+ assert set(packages) == {"real_pkg", "shim_pkg"}
32
+
33
+
34
+ def test_distributions_packages_values_are_lists():
35
+ """Every dist maps to a list of its import packages."""
36
+ core = Core({})
37
+ assert all(isinstance(v, list) for v in core.distributions_packages.values())
38
+ # bigraph-schema is always present and maps to the bigraph_schema package.
39
+ assert "bigraph_schema" in core.distributions_packages.get("bigraph-schema", [])
@@ -0,0 +1,57 @@
1
+ # tests/test_process_contract.py
2
+ from bigraph_schema.contract import ProcessContract, resolve_contract
3
+
4
+ DESC = """Distributes activated RNAPs across TUs by weighted multinomial sampling.
5
+
6
+ n_to_activate = round(f_active · n_total_RNAP) - n_active
7
+ p_i = max(0, basal_prob_i + sum_j delta_prob[i,j] · bound_TF_j)
8
+ """
9
+
10
+ def test_mutable_defaults_isolated():
11
+ a, b = ProcessContract(summary="a"), ProcessContract(summary="b")
12
+ a.inputs["x"] = "y"
13
+ assert b.inputs == {}
14
+
15
+ def test_from_description_splits_summary_and_math():
16
+ c = ProcessContract.from_description(DESC)
17
+ assert c.summary.startswith("Distributes activated RNAPs")
18
+ assert len(c.math) == 2 and c.math[0].startswith("n_to_activate =")
19
+
20
+ def test_from_description_none():
21
+ assert ProcessContract.from_description("") is None
22
+ assert ProcessContract.from_description(None) is None
23
+
24
+ def test_merged_preserves_authored_rows_fills_math():
25
+ authored = ProcessContract(inputs={"RNAs": "reads transcripts"})
26
+ merged = authored.merged_with_description(DESC)
27
+ assert merged.inputs == {"RNAs": "reads transcripts"} # untouched
28
+ assert merged.math and merged.summary # filled from desc
29
+
30
+ def test_merged_does_not_override_authored_math():
31
+ authored = ProcessContract(summary="mine", math=["x = 1"])
32
+ merged = authored.merged_with_description(DESC)
33
+ assert merged.summary == "mine" and merged.math == ["x = 1"]
34
+
35
+ class _Declared:
36
+ contract = ProcessContract(inputs={"p": "reads p"})
37
+ description = DESC
38
+
39
+ class _DescOnly:
40
+ description = DESC
41
+
42
+ class _Bare:
43
+ """Plain docstring, no math."""
44
+
45
+ def test_resolve_declared_merges_description():
46
+ c = resolve_contract(_Declared())
47
+ assert c.inputs == {"p": "reads p"} # authored
48
+ assert c.math # merged from description
49
+
50
+ def test_resolve_description_only():
51
+ c = resolve_contract(_DescOnly())
52
+ assert c.math and not c.inputs
53
+
54
+ def test_resolve_docstring_fallback_then_none_safe():
55
+ assert resolve_contract(_Bare()).summary == "Plain docstring, no math."
56
+ assert resolve_contract(None) is None
57
+ assert resolve_contract(object()) is None
File without changes
File without changes
File without changes
File without changes