distparser 0.2.0__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distparser
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Parse function-call strings into frozen scipy.stats distributions
5
5
  Author-email: Juyoung Choi <njuyoung35@gmail.com>
6
6
  Maintainer-email: Juyoung Choi <njuyoung35@gmail.com>
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: scipy>=1.7.0
20
20
  Requires-Dist: numpy>=1.21.0
21
+ Requires-Dist: asteval>=0.9.0
21
22
  Provides-Extra: dev
22
23
  Requires-Dist: pytest>=7.0; extra == "dev"
23
24
  Requires-Dist: pytest-cov; extra == "dev"
@@ -27,25 +28,18 @@ Requires-Dist: build; extra == "dev"
27
28
  Requires-Dist: twine; extra == "dev"
28
29
  Provides-Extra: docs
29
30
  Requires-Dist: mkdocs>=1.5.0; extra == "docs"
30
- Requires-Dist: mkdocs-rtd-theme>=2.0.0; extra == "docs"
31
31
  Requires-Dist: pymdown-extensions>=10.0; extra == "docs"
32
32
  Dynamic: license-file
33
33
 
34
34
  # distparser
35
35
 
36
- **Parse function‑call syntax strings into frozen `scipy.stats` distributions**
36
+ **Parse function‑call strings into frozen `scipy.stats` distributions —**
37
+ **with dependency resolution, arithmetic expressions, and seed management.**
37
38
 
38
39
  [![PyPI version](https://badge.fury.io/py/distparser.svg)](https://badge.fury.io/py/distparser)
39
40
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
40
41
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
41
42
 
42
- ## Why `distparser`?
43
-
44
- When you have configuration files or user input that specifies a probability
45
- distribution (e.g. `"norm(loc=0, scale=1)"`), you want to turn that string
46
- into a ready‑to‑sample object. `distparser` does exactly that, with a tiny
47
- API and zero surprises.
48
-
49
43
  ## Installation
50
44
 
51
45
  ```bash
@@ -54,42 +48,47 @@ pip install distparser
54
48
 
55
49
  ## Quick Start
56
50
 
57
- ```
58
- from distparser import parse_dist
51
+ ```python
52
+ import distparser as lib
59
53
 
60
- # Positional arguments (order matters)
61
- dist = parse_dist("uniform(0, 1)")
62
- sample = dist.rvs() # e.g. 0.374
54
+ # Parse a single distribution string
55
+ d = lib.parse_dist("norm(loc=0, scale=1)")
56
+ print(d.rvs(size=3)) # array([...])
57
+ ```
63
58
 
64
- # Keyword arguments (order ignored)
65
- dist = parse_dist("norm(loc=0, scale=1)")
66
- samples = dist.rvs(size=5) # array([-0.12, 1.03, ...])
59
+ ## DistGraph dependency-aware evaluation
67
60
 
68
- # Mixed (positional + keyword) – works, but not recommended
69
- dist = parse_dist("expon(scale=2, loc=1)")
70
- ```
61
+ ```python
62
+ from distparser import DistGraph
71
63
 
72
- ## Supported Distributions
64
+ config = {
65
+ "mean": 5.0,
66
+ "noise": "norm(0, 0.1)",
67
+ "measurement": "mean + noise",
68
+ }
73
69
 
74
- Built‑in registry covers the most common continuous distributions:
70
+ graph = DistGraph(config, seed=42)
71
+ result = graph.resolve_all()
72
+ print(result["measurement"]) # 5.0 + sample from N(0, 0.1)
73
+ ```
75
74
 
76
- - `uniform(loc, scale)`
77
- - `norm(loc, scale)`
78
- - `expon(loc, scale)`
79
- - … and many more (see [docs](https://distparser.readthedocs.io)).
75
+ ## Features
80
76
 
81
- You can **add your own** at runtime:
77
+ - **15 built-in distributions** `uniform`, `norm`, `expon`, `gamma`, `beta`, `lognorm`, `weibull_min`, `t`, `chi2`, `f`, `pareto`, `cauchy`, `laplace`, `logistic`, `rayleigh`
78
+ - **Distribution aliases** — `normal` → `norm`, `gaussian` → `norm`, `unif` → `uniform`
79
+ - **DistGraph** — automatic dependency resolution via topological sort
80
+ - **Arithmetic expressions** — `"60 + 30 * uniform(0, 1)"` with `sin`, `cos`, `exp`, `sqrt`, `pi` and more
81
+ - **Seed management** — global `seed()`, context manager `seed_context()`, per-instance `DistGraph(seed=...)`
82
+ - **Bounds constraints** — annotate parameters with physical limits for downstream validation
83
+ - **Extensible registry** — register custom distributions at runtime
82
84
 
83
- ```python
84
- from distparser import register_distribution
85
- from scipy.stats import gumbel_r
85
+ ## Docs
86
86
 
87
- register_distribution("gumbel", gumbel_r, ["loc", "scale"])
88
- ```
87
+ Full documentation at [distparser.readthedocs.io](https://distparser.readthedocs.io).
89
88
 
90
89
  ## Development
91
90
 
92
- See [AGENTS.md](AGENTS.md) for development setup and guidelines.
91
+ See [AGENTS.md](https://github.com/njuyoung35/distparser/blob/main/AGENTS.md) for setup and guidelines.
93
92
 
94
93
  ## License
95
94
 
@@ -0,0 +1,62 @@
1
+ # distparser
2
+
3
+ **Parse function‑call strings into frozen `scipy.stats` distributions —**
4
+ **with dependency resolution, arithmetic expressions, and seed management.**
5
+
6
+ [![PyPI version](https://badge.fury.io/py/distparser.svg)](https://badge.fury.io/py/distparser)
7
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install distparser
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ```python
19
+ import distparser as lib
20
+
21
+ # Parse a single distribution string
22
+ d = lib.parse_dist("norm(loc=0, scale=1)")
23
+ print(d.rvs(size=3)) # array([...])
24
+ ```
25
+
26
+ ## DistGraph — dependency-aware evaluation
27
+
28
+ ```python
29
+ from distparser import DistGraph
30
+
31
+ config = {
32
+ "mean": 5.0,
33
+ "noise": "norm(0, 0.1)",
34
+ "measurement": "mean + noise",
35
+ }
36
+
37
+ graph = DistGraph(config, seed=42)
38
+ result = graph.resolve_all()
39
+ print(result["measurement"]) # 5.0 + sample from N(0, 0.1)
40
+ ```
41
+
42
+ ## Features
43
+
44
+ - **15 built-in distributions** — `uniform`, `norm`, `expon`, `gamma`, `beta`, `lognorm`, `weibull_min`, `t`, `chi2`, `f`, `pareto`, `cauchy`, `laplace`, `logistic`, `rayleigh`
45
+ - **Distribution aliases** — `normal` → `norm`, `gaussian` → `norm`, `unif` → `uniform`
46
+ - **DistGraph** — automatic dependency resolution via topological sort
47
+ - **Arithmetic expressions** — `"60 + 30 * uniform(0, 1)"` with `sin`, `cos`, `exp`, `sqrt`, `pi` and more
48
+ - **Seed management** — global `seed()`, context manager `seed_context()`, per-instance `DistGraph(seed=...)`
49
+ - **Bounds constraints** — annotate parameters with physical limits for downstream validation
50
+ - **Extensible registry** — register custom distributions at runtime
51
+
52
+ ## Docs
53
+
54
+ Full documentation at [distparser.readthedocs.io](https://distparser.readthedocs.io).
55
+
56
+ ## Development
57
+
58
+ See [AGENTS.md](https://github.com/njuyoung35/distparser/blob/main/AGENTS.md) for setup and guidelines.
59
+
60
+ ## License
61
+
62
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "distparser"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Parse function-call strings into frozen scipy.stats distributions"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -21,6 +21,7 @@ requires-python = ">=3.8,<3.14"
21
21
  dependencies = [
22
22
  "scipy>=1.7.0",
23
23
  "numpy>=1.21.0",
24
+ "asteval>=0.9.0",
24
25
  ]
25
26
 
26
27
  [project.urls]
@@ -39,7 +40,6 @@ dev = [
39
40
  ]
40
41
  docs = [
41
42
  "mkdocs>=1.5.0",
42
- "mkdocs-rtd-theme>=2.0.0",
43
43
  "pymdown-extensions>=10.0",
44
44
  ]
45
45
 
@@ -52,12 +52,16 @@ testpaths = ["tests"]
52
52
  addopts = "-v --cov=src/distparser --cov-report=term-missing"
53
53
 
54
54
  [tool.mypy]
55
- python_version = "3.8"
55
+ python_version = "3.12"
56
56
  strict = true
57
57
  warn_return_any = true
58
58
  warn_unused_configs = true
59
59
  ignore_missing_imports = true
60
60
 
61
+ [[tool.mypy.overrides]]
62
+ module = ["scipy.stats", "asteval"]
63
+ ignore_missing_imports = true
64
+
61
65
  [tool.ruff]
62
66
  line-length = 88
63
67
  target-version = "py38"
@@ -8,6 +8,8 @@ from distparser.core import (
8
8
  parse_dist,
9
9
  register_distribution,
10
10
  )
11
+ from distparser.graph import DistGraph, seed, seed_context
12
+ from distparser.mapping import DISTRIBUTION_ALIASES, normalize_dist_name
11
13
  from distparser.version import __version__
12
14
 
13
15
  __all__ = [
@@ -16,6 +18,11 @@ __all__ = [
16
18
  "ParseError",
17
19
  "UnknownDistributionError",
18
20
  "REGISTRY",
21
+ "DISTRIBUTION_ALIASES",
22
+ "normalize_dist_name",
19
23
  "parse_dist",
20
24
  "register_distribution",
25
+ "DistGraph",
26
+ "seed",
27
+ "seed_context",
21
28
  ]
@@ -24,6 +24,8 @@ from scipy.stats import (
24
24
  weibull_min,
25
25
  )
26
26
 
27
+ from distparser.mapping import normalize_dist_name
28
+
27
29
  __all__ = [
28
30
  "DistParserError",
29
31
  "ParseError",
@@ -154,7 +156,7 @@ def parse_dist(s: str) -> Any:
154
156
  if not m:
155
157
  raise ParseError(f"Cannot parse {s!r}. Expected format: 'name(args)'.")
156
158
 
157
- name = m.group(1)
159
+ name = normalize_dist_name(m.group(1))
158
160
  if name not in REGISTRY:
159
161
  raise UnknownDistributionError(
160
162
  f"Unknown distribution {name!r}. Registered: {', '.join(sorted(REGISTRY))}."
@@ -0,0 +1,272 @@
1
+ """DistGraph: dependency-aware distribution graph with safe expression evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast as _ast
6
+ import contextlib
7
+ from typing import Any, Iterator
8
+
9
+ import graphlib
10
+ import numpy as np
11
+ from asteval import Interpreter
12
+
13
+ from distparser.core import REGISTRY, ParseError
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Seed management
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _DEFAULT_SEED: int | None = None
20
+ _DEFAULT_RNG: np.random.Generator | None = None
21
+
22
+
23
+ def seed(value: int) -> None:
24
+ """Set the global legacy seed for distribution sampling."""
25
+ global _DEFAULT_SEED, _DEFAULT_RNG
26
+ _DEFAULT_SEED = value
27
+ _DEFAULT_RNG = np.random.default_rng(value)
28
+
29
+
30
+ @contextlib.contextmanager
31
+ def seed_context(value: int) -> Iterator[None]:
32
+ """Temporarily override the global seed within a ``with`` block.
33
+
34
+ Example:
35
+ >>> with seed_context(42):
36
+ ... graph = DistGraph({"x": "uniform(0,1)"})
37
+ ... graph.resolve_all()
38
+ """
39
+ global _DEFAULT_RNG
40
+ old_rng = _DEFAULT_RNG
41
+ _DEFAULT_RNG = np.random.default_rng(value)
42
+ try:
43
+ yield
44
+ finally:
45
+ _DEFAULT_RNG = old_rng
46
+
47
+
48
+ def _get_current_rng() -> np.random.Generator:
49
+ """Return the current global RNG, creating a fresh one if unset."""
50
+ global _DEFAULT_RNG
51
+ if _DEFAULT_RNG is None:
52
+ _DEFAULT_RNG = np.random.default_rng()
53
+ return _DEFAULT_RNG
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Distribution sampler factory
58
+ # ---------------------------------------------------------------------------
59
+
60
+ # Math constants and functions exposed in expressions
61
+ _MATH_SYMBOLS: dict[str, Any] = {
62
+ "sin": np.sin,
63
+ "cos": np.cos,
64
+ "tan": np.tan,
65
+ "exp": np.exp,
66
+ "log": np.log,
67
+ "log10": np.log10,
68
+ "sqrt": np.sqrt,
69
+ "abs": abs,
70
+ "pi": np.pi,
71
+ "e": np.e,
72
+ }
73
+
74
+
75
+ def _make_dist_samplers(rng: np.random.Generator) -> dict[str, Any]:
76
+ """Build a dict mapping each registered distribution name to a callable
77
+ sampler that returns a single ``.rvs()`` sample."""
78
+ samplers: dict[str, Any] = {}
79
+ for name, entry in REGISTRY.items():
80
+ dist_class = entry["class"]
81
+ param_order: list[str] = entry["param_order"]
82
+
83
+ def _sampler(
84
+ *args: Any,
85
+ _dc: Any = dist_class,
86
+ _po: list[str] = param_order,
87
+ _rng: np.random.Generator = rng,
88
+ **kwargs: Any,
89
+ ) -> Any:
90
+ params: dict[str, Any] = {}
91
+ for i, val in enumerate(args):
92
+ if i < len(_po):
93
+ params[_po[i]] = val
94
+ params.update(kwargs)
95
+ return _dc(**params).rvs(random_state=_rng)
96
+
97
+ samplers[name] = _sampler
98
+ return samplers
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # DistGraph
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ class DistGraph:
107
+ """A dependency-aware graph of distribution expressions.
108
+
109
+ Users supply a flat dictionary mapping keys to expressions (or numeric
110
+ values). ``DistGraph`` detects cross-references, resolves evaluation
111
+ order via topological sort, and injects resolved values into subsequent
112
+ expressions.
113
+
114
+ Parameters
115
+ ----------
116
+ config : dict
117
+ Mapping of key → expression string, numeric value, or bounds dict.
118
+ See the user guide for the bounds dict format.
119
+ seed : int | None
120
+ Per-instance RNG seed. Overrides any global seed setting.
121
+ """
122
+
123
+ def __init__(self, config: dict[str, Any], seed: int | None = None) -> None:
124
+ self._raw_config = config
125
+ self._rng = np.random.default_rng(seed) if seed is not None else None
126
+ self._resolved: dict[str, Any] = {}
127
+ self._bounds: dict[str, dict[str, tuple[float, float]]] = {}
128
+
129
+ # Parse config into dist expressions + bounds
130
+ self._dists: dict[str, Any] = {}
131
+ self._parse_config()
132
+
133
+ # Build dependency graph and resolve evaluation order
134
+ self._order = self._resolve_order()
135
+
136
+ # ------------------------------------------------------------------
137
+ # Config parsing
138
+ # ------------------------------------------------------------------
139
+
140
+ def _parse_config(self) -> None:
141
+ for key, value in self._raw_config.items():
142
+ if isinstance(value, dict):
143
+ if "dist" not in value:
144
+ raise ParseError(
145
+ f"Config key {key!r} is a dict but missing required "
146
+ f"'dist' key. Use 'dist' for the expression string."
147
+ )
148
+ self._dists[key] = value["dist"]
149
+ if "bounds" in value:
150
+ self._bounds[key] = value["bounds"]
151
+ else:
152
+ self._dists[key] = value
153
+
154
+ # ------------------------------------------------------------------
155
+ # Dependency resolution
156
+ # ------------------------------------------------------------------
157
+
158
+ def _resolve_order(self) -> list[str]:
159
+ graph: dict[str, set[str]] = {k: set() for k in self._dists}
160
+ for key, expr in self._dists.items():
161
+ if isinstance(expr, str):
162
+ for var in self._extract_variables(expr):
163
+ if var in self._dists:
164
+ graph[key].add(var)
165
+ try:
166
+ ts = graphlib.TopologicalSorter(graph)
167
+ return list(ts.static_order())
168
+ except graphlib.CycleError as exc:
169
+ raise ParseError(f"Circular dependency detected in config: {exc}") from exc
170
+
171
+ @staticmethod
172
+ def _extract_variables(expr: str) -> set[str]:
173
+ """Return the set of bare-name identifiers in *expr*."""
174
+ try:
175
+ tree = _ast.parse(expr, mode="eval")
176
+ except SyntaxError:
177
+ return set()
178
+ return {node.id for node in _ast.walk(tree) if isinstance(node, _ast.Name)}
179
+
180
+ # ------------------------------------------------------------------
181
+ # Evaluation
182
+ # ------------------------------------------------------------------
183
+
184
+ def _evaluate(self, key: str) -> Any:
185
+ expr = self._dists[key]
186
+ if not isinstance(expr, str):
187
+ return expr
188
+
189
+ rng = self._rng or _get_current_rng()
190
+
191
+ interp = Interpreter()
192
+ # Pre-populate with already-resolved dependencies
193
+ interp.symtable.update(self._resolved)
194
+ # Math symbols
195
+ interp.symtable.update(_MATH_SYMBOLS)
196
+ # Distribution samplers
197
+ interp.symtable.update(_make_dist_samplers(rng))
198
+
199
+ result = interp(expr)
200
+ if interp.error:
201
+ messages: list[str] = []
202
+ for err in interp.error:
203
+ messages.append(f"{err.msg} (line {err.lineno})")
204
+ raise ParseError(
205
+ f"Error evaluating {expr!r} for key {key!r}: " + "; ".join(messages)
206
+ )
207
+ return result
208
+
209
+ def resolve_all(self) -> dict[str, Any]:
210
+ """Evaluate all keys in dependency order and return resolved values."""
211
+ for key in self._order:
212
+ if key not in self._resolved:
213
+ self._resolved[key] = self._evaluate(key)
214
+ return dict(self._resolved)
215
+
216
+ # ------------------------------------------------------------------
217
+ # Access
218
+ # ------------------------------------------------------------------
219
+
220
+ def __getitem__(self, key: str) -> Any:
221
+ """Access a resolved value, evaluating on demand if needed."""
222
+ if key not in self._dists:
223
+ raise KeyError(key)
224
+ if key not in self._resolved:
225
+ # Build the dependency graph to find what 'key' needs
226
+ graph = self._build_dep_graph()
227
+ self._resolve_deps(key, graph)
228
+ return self._resolved[key]
229
+
230
+ def _build_dep_graph(self) -> dict[str, set[str]]:
231
+ """Build the dependency graph (key → set of keys it depends on)."""
232
+ graph: dict[str, set[str]] = {k: set() for k in self._dists}
233
+ for k, expr in self._dists.items():
234
+ if isinstance(expr, str):
235
+ for var in self._extract_variables(expr):
236
+ if var in self._dists:
237
+ graph[k].add(var)
238
+ return graph
239
+
240
+ def _resolve_deps(self, key: str, graph: dict[str, set[str]]) -> None:
241
+ """Recursively resolve dependencies of *key*, then evaluate *key*."""
242
+ if key in self._resolved:
243
+ return
244
+ for dep in graph.get(key, set()):
245
+ self._resolve_deps(dep, graph)
246
+ self._resolved[key] = self._evaluate(key)
247
+
248
+ def __contains__(self, key: str) -> bool:
249
+ return key in self._dists
250
+
251
+ def __len__(self) -> int:
252
+ return len(self._dists)
253
+
254
+ def __iter__(self) -> Iterator[str]:
255
+ return iter(self._dists)
256
+
257
+ def keys(self) -> Any:
258
+ return self._dists.keys()
259
+
260
+ def get_bounds(self, key: str) -> dict[str, tuple[float, float]] | None:
261
+ """Return the bounds dict for *key*, or ``None`` if no bounds set."""
262
+ return self._bounds.get(key)
263
+
264
+ @property
265
+ def order(self) -> list[str]:
266
+ """The topological evaluation order (read-only)."""
267
+ return list(self._order)
268
+
269
+ def __repr__(self) -> str:
270
+ resolved_count = len(self._resolved)
271
+ total = len(self._dists)
272
+ return f"<DistGraph keys={total} resolved={resolved_count}>"
@@ -0,0 +1,18 @@
1
+ """Distribution name aliases and canonicalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ DISTRIBUTION_ALIASES: dict[str, str] = {
6
+ "normal": "norm",
7
+ "gaussian": "norm",
8
+ "unif": "uniform",
9
+ }
10
+
11
+
12
+ def normalize_dist_name(name: str) -> str:
13
+ """Return the canonical distribution name for *name*.
14
+
15
+ If *name* is a known alias, the canonical name is returned.
16
+ Otherwise *name* is returned unchanged.
17
+ """
18
+ return DISTRIBUTION_ALIASES.get(name, name)
@@ -0,0 +1 @@
1
+ __version__ = "0.3.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distparser
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Parse function-call strings into frozen scipy.stats distributions
5
5
  Author-email: Juyoung Choi <njuyoung35@gmail.com>
6
6
  Maintainer-email: Juyoung Choi <njuyoung35@gmail.com>
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: scipy>=1.7.0
20
20
  Requires-Dist: numpy>=1.21.0
21
+ Requires-Dist: asteval>=0.9.0
21
22
  Provides-Extra: dev
22
23
  Requires-Dist: pytest>=7.0; extra == "dev"
23
24
  Requires-Dist: pytest-cov; extra == "dev"
@@ -27,25 +28,18 @@ Requires-Dist: build; extra == "dev"
27
28
  Requires-Dist: twine; extra == "dev"
28
29
  Provides-Extra: docs
29
30
  Requires-Dist: mkdocs>=1.5.0; extra == "docs"
30
- Requires-Dist: mkdocs-rtd-theme>=2.0.0; extra == "docs"
31
31
  Requires-Dist: pymdown-extensions>=10.0; extra == "docs"
32
32
  Dynamic: license-file
33
33
 
34
34
  # distparser
35
35
 
36
- **Parse function‑call syntax strings into frozen `scipy.stats` distributions**
36
+ **Parse function‑call strings into frozen `scipy.stats` distributions —**
37
+ **with dependency resolution, arithmetic expressions, and seed management.**
37
38
 
38
39
  [![PyPI version](https://badge.fury.io/py/distparser.svg)](https://badge.fury.io/py/distparser)
39
40
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
40
41
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
41
42
 
42
- ## Why `distparser`?
43
-
44
- When you have configuration files or user input that specifies a probability
45
- distribution (e.g. `"norm(loc=0, scale=1)"`), you want to turn that string
46
- into a ready‑to‑sample object. `distparser` does exactly that, with a tiny
47
- API and zero surprises.
48
-
49
43
  ## Installation
50
44
 
51
45
  ```bash
@@ -54,42 +48,47 @@ pip install distparser
54
48
 
55
49
  ## Quick Start
56
50
 
57
- ```
58
- from distparser import parse_dist
51
+ ```python
52
+ import distparser as lib
59
53
 
60
- # Positional arguments (order matters)
61
- dist = parse_dist("uniform(0, 1)")
62
- sample = dist.rvs() # e.g. 0.374
54
+ # Parse a single distribution string
55
+ d = lib.parse_dist("norm(loc=0, scale=1)")
56
+ print(d.rvs(size=3)) # array([...])
57
+ ```
63
58
 
64
- # Keyword arguments (order ignored)
65
- dist = parse_dist("norm(loc=0, scale=1)")
66
- samples = dist.rvs(size=5) # array([-0.12, 1.03, ...])
59
+ ## DistGraph dependency-aware evaluation
67
60
 
68
- # Mixed (positional + keyword) – works, but not recommended
69
- dist = parse_dist("expon(scale=2, loc=1)")
70
- ```
61
+ ```python
62
+ from distparser import DistGraph
71
63
 
72
- ## Supported Distributions
64
+ config = {
65
+ "mean": 5.0,
66
+ "noise": "norm(0, 0.1)",
67
+ "measurement": "mean + noise",
68
+ }
73
69
 
74
- Built‑in registry covers the most common continuous distributions:
70
+ graph = DistGraph(config, seed=42)
71
+ result = graph.resolve_all()
72
+ print(result["measurement"]) # 5.0 + sample from N(0, 0.1)
73
+ ```
75
74
 
76
- - `uniform(loc, scale)`
77
- - `norm(loc, scale)`
78
- - `expon(loc, scale)`
79
- - … and many more (see [docs](https://distparser.readthedocs.io)).
75
+ ## Features
80
76
 
81
- You can **add your own** at runtime:
77
+ - **15 built-in distributions** `uniform`, `norm`, `expon`, `gamma`, `beta`, `lognorm`, `weibull_min`, `t`, `chi2`, `f`, `pareto`, `cauchy`, `laplace`, `logistic`, `rayleigh`
78
+ - **Distribution aliases** — `normal` → `norm`, `gaussian` → `norm`, `unif` → `uniform`
79
+ - **DistGraph** — automatic dependency resolution via topological sort
80
+ - **Arithmetic expressions** — `"60 + 30 * uniform(0, 1)"` with `sin`, `cos`, `exp`, `sqrt`, `pi` and more
81
+ - **Seed management** — global `seed()`, context manager `seed_context()`, per-instance `DistGraph(seed=...)`
82
+ - **Bounds constraints** — annotate parameters with physical limits for downstream validation
83
+ - **Extensible registry** — register custom distributions at runtime
82
84
 
83
- ```python
84
- from distparser import register_distribution
85
- from scipy.stats import gumbel_r
85
+ ## Docs
86
86
 
87
- register_distribution("gumbel", gumbel_r, ["loc", "scale"])
88
- ```
87
+ Full documentation at [distparser.readthedocs.io](https://distparser.readthedocs.io).
89
88
 
90
89
  ## Development
91
90
 
92
- See [AGENTS.md](AGENTS.md) for development setup and guidelines.
91
+ See [AGENTS.md](https://github.com/njuyoung35/distparser/blob/main/AGENTS.md) for setup and guidelines.
93
92
 
94
93
  ## License
95
94
 
@@ -3,10 +3,14 @@ README.md
3
3
  pyproject.toml
4
4
  src/distparser/__init__.py
5
5
  src/distparser/core.py
6
+ src/distparser/graph.py
7
+ src/distparser/mapping.py
6
8
  src/distparser/version.py
7
9
  src/distparser.egg-info/PKG-INFO
8
10
  src/distparser.egg-info/SOURCES.txt
9
11
  src/distparser.egg-info/dependency_links.txt
10
12
  src/distparser.egg-info/requires.txt
11
13
  src/distparser.egg-info/top_level.txt
12
- tests/test_core.py
14
+ tests/test_core.py
15
+ tests/test_graph.py
16
+ tests/test_mapping.py
@@ -1,5 +1,6 @@
1
1
  scipy>=1.7.0
2
2
  numpy>=1.21.0
3
+ asteval>=0.9.0
3
4
 
4
5
  [dev]
5
6
  pytest>=7.0
@@ -11,5 +12,4 @@ twine
11
12
 
12
13
  [docs]
13
14
  mkdocs>=1.5.0
14
- mkdocs-rtd-theme>=2.0.0
15
15
  pymdown-extensions>=10.0
@@ -190,6 +190,29 @@ class TestParseDistNewDistributions:
190
190
  assert d.rvs(size=3, random_state=42).shape == (3,)
191
191
 
192
192
 
193
+ class TestParseDistAliases:
194
+ """Verify that distribution aliases are resolved."""
195
+
196
+ def test_normal_alias(self):
197
+ d = parse_dist("normal(loc=0, scale=1)")
198
+ assert abs(d.kwds["loc"] - 0.0) < 1e-12
199
+
200
+ def test_gaussian_alias(self):
201
+ d = parse_dist("gaussian(loc=5, scale=2)")
202
+ assert abs(d.kwds["loc"] - 5.0) < 1e-12
203
+
204
+ def test_unif_alias(self):
205
+ d = parse_dist("unif(0, 1)")
206
+ assert abs(d.kwds["loc"] - 0.0) < 1e-12
207
+
208
+ def test_unknown_distribution_with_alias_message(self):
209
+ # Even with normalize, unknown names still raise UnknownDistributionError
210
+ from distparser.core import UnknownDistributionError
211
+
212
+ with pytest.raises(UnknownDistributionError):
213
+ parse_dist("not_an_alias(0, 1)")
214
+
215
+
193
216
  # ---------------------------------------------------------------------------
194
217
  # parse_dist – error cases
195
218
  # ---------------------------------------------------------------------------
@@ -0,0 +1,260 @@
1
+ """Tests for distparser.graph (DistGraph + seed management)."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from distparser.core import ParseError
7
+ from distparser.graph import DistGraph, seed, seed_context
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Simple configs — no dependencies
11
+ # ---------------------------------------------------------------------------
12
+
13
+
14
+ class TestDistGraphSimple:
15
+ def test_single_distribution(self):
16
+ g = DistGraph({"x": "uniform(0, 1)"})
17
+ result = g.resolve_all()
18
+ assert "x" in result
19
+ assert 0 <= result["x"] <= 1
20
+
21
+ def test_numeric_value_passthrough(self):
22
+ g = DistGraph({"a": 42, "b": 3.14})
23
+ result = g.resolve_all()
24
+ assert result["a"] == 42
25
+ assert result["b"] == 3.14
26
+
27
+ def test_empty_config(self):
28
+ g = DistGraph({})
29
+ result = g.resolve_all()
30
+ assert result == {}
31
+
32
+ def test_arithmetic_expression_no_deps(self):
33
+ g = DistGraph({"x": "60 + 30 * uniform(0, 1)"})
34
+ result = g.resolve_all()
35
+ assert 60 <= result["x"] <= 90
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Dependencies — cross-references between keys
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ class TestDistGraphDependencies:
44
+ def test_linear_chain(self):
45
+ g = DistGraph({"a": 10, "b": "a + 5", "c": "b * 2"})
46
+ result = g.resolve_all()
47
+ assert result["a"] == 10
48
+ assert result["b"] == 15
49
+ assert result["c"] == 30
50
+
51
+ def test_topological_order(self):
52
+ g = DistGraph({"a": 1, "b": "a + 1", "c": "a + b"})
53
+ result = g.resolve_all()
54
+ assert result["a"] == 1
55
+ assert result["b"] == 2
56
+ assert result["c"] == 3
57
+ # 'a' must come before 'b' and 'c'
58
+ order = g.order
59
+ assert order.index("a") < order.index("b")
60
+ assert order.index("a") < order.index("c")
61
+
62
+ def test_distribution_with_dependency(self):
63
+ g = DistGraph({"mean": 5.0, "x": "norm(mean, 0.1)"}, seed=42)
64
+ result = g.resolve_all()
65
+ assert result["mean"] == 5.0
66
+ assert abs(result["x"] - 5.0) < 1.0 # should be near mean
67
+
68
+ def test_separate_sampling_per_evaluation(self):
69
+ g = DistGraph({"a": "uniform(0, 1)", "b": "uniform(0, 1)"}, seed=42)
70
+ result = g.resolve_all()
71
+ # With same seed but separate calls, patterns should differ
72
+ assert result["a"] != result["b"]
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Circular dependencies
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ class TestDistGraphCycles:
81
+ def test_direct_cycle_detected(self):
82
+ with pytest.raises(ParseError, match="Circular"):
83
+ DistGraph({"a": "b + 1", "b": "a + 1"})
84
+
85
+ def test_indirect_cycle_detected(self):
86
+ with pytest.raises(ParseError, match="Circular"):
87
+ DistGraph({"a": "b", "b": "c", "c": "a"})
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # __getitem__ access
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ class TestDistGraphGetItem:
96
+ def test_lazy_resolution(self):
97
+ g = DistGraph({"a": 1, "b": "a + 2"})
98
+ assert g["a"] == 1
99
+ assert g["b"] == 3
100
+
101
+ def test_missing_key(self):
102
+ g = DistGraph({"a": 1})
103
+ with pytest.raises(KeyError):
104
+ _ = g["nonexistent"]
105
+
106
+ def test_contains_and_len(self):
107
+ g = DistGraph({"a": 1, "b": "a + 2"})
108
+ assert "a" in g
109
+ assert "c" not in g
110
+ assert len(g) == 2
111
+
112
+ def test_iter_and_keys(self):
113
+ g = DistGraph({"a": 1, "b": 2})
114
+ assert set(g) == {"a", "b"}
115
+ assert set(g.keys()) == {"a", "b"}
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Seed management
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ class TestSeedManagement:
124
+ def test_global_seed_reproducible(self):
125
+ seed(42)
126
+ g1 = DistGraph({"x": "uniform(0, 1)"})
127
+ v1 = g1.resolve_all()["x"]
128
+
129
+ seed(42)
130
+ g2 = DistGraph({"x": "uniform(0, 1)"})
131
+ v2 = g2.resolve_all()["x"]
132
+
133
+ assert v1 == v2
134
+
135
+ def test_per_instance_seed_overrides_global(self):
136
+ seed(99)
137
+ g1 = DistGraph({"x": "uniform(0, 1)"}, seed=42)
138
+ g2 = DistGraph({"x": "uniform(0, 1)"}, seed=42)
139
+ assert g1.resolve_all()["x"] == g2.resolve_all()["x"]
140
+
141
+ def test_seed_context_manager(self):
142
+ seed(0)
143
+ with seed_context(42):
144
+ g = DistGraph({"x": "uniform(0, 1)"})
145
+ v_ctx = g.resolve_all()["x"]
146
+
147
+ # Outside context, back to seed(0) RNG
148
+ g2 = DistGraph({"x": "uniform(0, 1)"})
149
+ v_outside = g2.resolve_all()["x"]
150
+
151
+ # Different seeds should produce different values (usually)
152
+ assert v_ctx != v_outside
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Bounds
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ class TestDistGraphBounds:
161
+ def test_bounds_dict_format(self):
162
+ g = DistGraph(
163
+ {
164
+ "wall": {
165
+ "dist": "norm(loc=0.25, scale=0.025)",
166
+ "bounds": {"loc": (0.0, 1.0), "scale": (0.001, 0.1)},
167
+ }
168
+ }
169
+ )
170
+ result = g.resolve_all()
171
+ assert abs(result["wall"] - 0.25) < 0.2
172
+
173
+ def test_get_bounds(self):
174
+ g = DistGraph({"x": {"dist": "uniform(0, 1)", "bounds": {"loc": (-1, 2)}}})
175
+ bounds = g.get_bounds("x")
176
+ assert bounds == {"loc": (-1, 2)}
177
+
178
+ def test_get_bounds_none(self):
179
+ g = DistGraph({"x": "uniform(0, 1)"})
180
+ assert g.get_bounds("x") is None
181
+
182
+ def test_missing_dist_key_raises(self):
183
+ with pytest.raises(ParseError, match="missing required 'dist'"):
184
+ DistGraph({"x": {"bounds": {"loc": (0, 1)}}})
185
+
186
+ def test_bounds_not_used_for_sampling(self):
187
+ # bounds are metadata only — sampling should still work
188
+ g = DistGraph(
189
+ {
190
+ "x": {
191
+ "dist": "norm(loc=100, scale=0.01)",
192
+ "bounds": {"loc": (0.0, 1.0)},
193
+ }
194
+ },
195
+ seed=42,
196
+ )
197
+ result = g.resolve_all()
198
+ # Value is near loc=100, not clipped to bounds (0, 1)
199
+ assert abs(result["x"] - 100.0) < 1.0
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Math expressions
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ class TestDistGraphMath:
208
+ def test_sin_cos(self):
209
+ g = DistGraph({"x": "sin(0)", "y": "cos(0)"})
210
+ result = g.resolve_all()
211
+ assert abs(result["x"]) < 1e-12
212
+ assert abs(result["y"] - 1.0) < 1e-12
213
+
214
+ def test_pi(self):
215
+ g = DistGraph({"x": "pi"})
216
+ result = g.resolve_all()
217
+ assert abs(result["x"] - np.pi) < 1e-12
218
+
219
+ def test_sqrt(self):
220
+ g = DistGraph({"x": "sqrt(16)"})
221
+ result = g.resolve_all()
222
+ assert abs(result["x"] - 4.0) < 1e-12
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Error cases
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ class TestDistGraphErrors:
231
+ def test_invalid_expression(self):
232
+ g = DistGraph({"x": "1 / 0"})
233
+ with pytest.raises(ParseError, match="Error evaluating"):
234
+ g.resolve_all()
235
+
236
+ def test_undefined_variable(self):
237
+ g = DistGraph({"x": "unknown_func(0)"})
238
+ with pytest.raises(ParseError, match="Error evaluating"):
239
+ g.resolve_all()
240
+
241
+ def test_syntax_error_expression_graceful(self):
242
+ # Invalid Python syntax → extract returns empty set, init succeeds
243
+ g = DistGraph({"bad": "x +", "ok": "42"})
244
+ # 'ok' has no deps and is valid — resolves fine
245
+ assert g["ok"] == 42
246
+ # 'bad' has invalid syntax — evaluation raises
247
+ with pytest.raises(ParseError, match="Error evaluating"):
248
+ g.resolve_all()
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Repr
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ class TestDistGraphRepr:
257
+ def test_repr(self):
258
+ g = DistGraph({"a": 1, "b": "a + 2"})
259
+ assert "DistGraph" in repr(g)
260
+ assert "keys=2" in repr(g)
@@ -0,0 +1,26 @@
1
+ """Tests for distparser.mapping."""
2
+
3
+ from distparser.mapping import DISTRIBUTION_ALIASES, normalize_dist_name
4
+
5
+
6
+ class TestNormalizeDistName:
7
+ def test_known_alias(self):
8
+ assert normalize_dist_name("normal") == "norm"
9
+ assert normalize_dist_name("gaussian") == "norm"
10
+ assert normalize_dist_name("unif") == "uniform"
11
+
12
+ def test_unknown_name_passthrough(self):
13
+ assert normalize_dist_name("norm") == "norm"
14
+ assert normalize_dist_name("gamma") == "gamma"
15
+ assert normalize_dist_name("custom_dist") == "custom_dist"
16
+
17
+ def test_case_sensitive(self):
18
+ assert normalize_dist_name("Normal") == "Normal"
19
+ assert normalize_dist_name("NORM") == "NORM"
20
+
21
+
22
+ class TestAliasMap:
23
+ def test_contains_expected_aliases(self):
24
+ assert DISTRIBUTION_ALIASES["normal"] == "norm"
25
+ assert DISTRIBUTION_ALIASES["gaussian"] == "norm"
26
+ assert DISTRIBUTION_ALIASES["unif"] == "uniform"
@@ -1,63 +0,0 @@
1
- # distparser
2
-
3
- **Parse function‑call syntax strings into frozen `scipy.stats` distributions**
4
-
5
- [![PyPI version](https://badge.fury.io/py/distparser.svg)](https://badge.fury.io/py/distparser)
6
- [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
7
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
-
9
- ## Why `distparser`?
10
-
11
- When you have configuration files or user input that specifies a probability
12
- distribution (e.g. `"norm(loc=0, scale=1)"`), you want to turn that string
13
- into a ready‑to‑sample object. `distparser` does exactly that, with a tiny
14
- API and zero surprises.
15
-
16
- ## Installation
17
-
18
- ```bash
19
- pip install distparser
20
- ```
21
-
22
- ## Quick Start
23
-
24
- ```
25
- from distparser import parse_dist
26
-
27
- # Positional arguments (order matters)
28
- dist = parse_dist("uniform(0, 1)")
29
- sample = dist.rvs() # e.g. 0.374
30
-
31
- # Keyword arguments (order ignored)
32
- dist = parse_dist("norm(loc=0, scale=1)")
33
- samples = dist.rvs(size=5) # array([-0.12, 1.03, ...])
34
-
35
- # Mixed (positional + keyword) – works, but not recommended
36
- dist = parse_dist("expon(scale=2, loc=1)")
37
- ```
38
-
39
- ## Supported Distributions
40
-
41
- Built‑in registry covers the most common continuous distributions:
42
-
43
- - `uniform(loc, scale)`
44
- - `norm(loc, scale)`
45
- - `expon(loc, scale)`
46
- - … and many more (see [docs](https://distparser.readthedocs.io)).
47
-
48
- You can **add your own** at runtime:
49
-
50
- ```python
51
- from distparser import register_distribution
52
- from scipy.stats import gumbel_r
53
-
54
- register_distribution("gumbel", gumbel_r, ["loc", "scale"])
55
- ```
56
-
57
- ## Development
58
-
59
- See [AGENTS.md](AGENTS.md) for development setup and guidelines.
60
-
61
- ## License
62
-
63
- MIT
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
File without changes
File without changes