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.
- {bigraph_schema-1.4.2/bigraph_schema.egg-info → bigraph_schema-1.4.3}/PKG-INFO +1 -1
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/__init__.py +1 -0
- bigraph_schema-1.4.3/bigraph_schema/contract.py +172 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/core.py +24 -7
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/edge.py +25 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/apply.py +0 -28
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/serialize.py +43 -7
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/package/discover.py +53 -1
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3/bigraph_schema.egg-info}/PKG-INFO +1 -1
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema.egg-info/SOURCES.txt +4 -1
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/pyproject.toml +1 -1
- bigraph_schema-1.4.3/tests/test_multi_package_dist_discovery.py +39 -0
- bigraph_schema-1.4.3/tests/test_process_contract.py +57 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/AUTHORS.md +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/LICENSE +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/README.md +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/assembly.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/calculi.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/json_codec.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/__init__.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/bundle.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/check.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/coerce.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/default.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/derive.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/diff.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/divide.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/events.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/generalize.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/handle_parameters.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/infer.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/is_empty.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/jump.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/merge.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/patch.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/realize.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/reconcile.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/resolve.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/select.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/transform.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/validate.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/methods/walk.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/package/__init__.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/parse.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/protocols.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/schema.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema/units.py +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema.egg-info/dependency_links.txt +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema.egg-info/requires.txt +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/bigraph_schema.egg-info/top_level.txt +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/setup.cfg +0 -0
- {bigraph_schema-1.4.2 → bigraph_schema-1.4.3}/setup.py +0 -0
|
@@ -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.
|
|
108
|
-
schema.
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|