hepyy-workflows 0.1.0__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. hepyy_workflows-0.1.0/PKG-INFO +113 -0
  2. hepyy_workflows-0.1.0/README.md +83 -0
  3. hepyy_workflows-0.1.0/pyproject.toml +47 -0
  4. hepyy_workflows-0.1.0/setup.cfg +4 -0
  5. hepyy_workflows-0.1.0/src/hepyy_workflows/__init__.py +3 -0
  6. hepyy_workflows-0.1.0/src/hepyy_workflows/agent/__init__.py +5 -0
  7. hepyy_workflows-0.1.0/src/hepyy_workflows/agent/service.py +80 -0
  8. hepyy_workflows-0.1.0/src/hepyy_workflows/agent/stdio.py +54 -0
  9. hepyy_workflows-0.1.0/src/hepyy_workflows/analysis/__init__.py +5 -0
  10. hepyy_workflows-0.1.0/src/hepyy_workflows/analysis/expressions.py +184 -0
  11. hepyy_workflows-0.1.0/src/hepyy_workflows/analysis/histograms.py +166 -0
  12. hepyy_workflows-0.1.0/src/hepyy_workflows/analysis/runner.py +738 -0
  13. hepyy_workflows-0.1.0/src/hepyy_workflows/analysis/views.py +142 -0
  14. hepyy_workflows-0.1.0/src/hepyy_workflows/cli.py +196 -0
  15. hepyy_workflows-0.1.0/src/hepyy_workflows/core/__init__.py +28 -0
  16. hepyy_workflows-0.1.0/src/hepyy_workflows/core/config.py +152 -0
  17. hepyy_workflows-0.1.0/src/hepyy_workflows/core/contracts.py +188 -0
  18. hepyy_workflows-0.1.0/src/hepyy_workflows/core/source.py +110 -0
  19. hepyy_workflows-0.1.0/src/hepyy_workflows/data/particles.parquet +0 -0
  20. hepyy_workflows-0.1.0/src/hepyy_workflows/examples.py +53 -0
  21. hepyy_workflows-0.1.0/src/hepyy_workflows/export/__init__.py +5 -0
  22. hepyy_workflows-0.1.0/src/hepyy_workflows/export/standalone.py +156 -0
  23. hepyy_workflows-0.1.0/src/hepyy_workflows/processing.py +65 -0
  24. hepyy_workflows-0.1.0/src/hepyy_workflows/project.py +38 -0
  25. hepyy_workflows-0.1.0/src/hepyy_workflows/readers/__init__.py +17 -0
  26. hepyy_workflows-0.1.0/src/hepyy_workflows/readers/formats.py +666 -0
  27. hepyy_workflows-0.1.0/src/hepyy_workflows/readers/mapped.py +608 -0
  28. hepyy_workflows-0.1.0/src/hepyy_workflows/readers/scaffold.py +68 -0
  29. hepyy_workflows-0.1.0/src/hepyy_workflows/readers/service.py +271 -0
  30. hepyy_workflows-0.1.0/src/hepyy_workflows/reporting/__init__.py +5 -0
  31. hepyy_workflows-0.1.0/src/hepyy_workflows/reporting/report.py +77 -0
  32. hepyy_workflows-0.1.0/src/hepyy_workflows/schemas/reader-draft.schema.json +45 -0
  33. hepyy_workflows-0.1.0/src/hepyy_workflows/schemas/reader.schema.json +31 -0
  34. hepyy_workflows-0.1.0/src/hepyy_workflows/schemas/recipe.schema.json +87 -0
  35. hepyy_workflows-0.1.0/src/hepyy_workflows/starter/README.md +22 -0
  36. hepyy_workflows-0.1.0/src/hepyy_workflows/starter/reader.yaml +20 -0
  37. hepyy_workflows-0.1.0/src/hepyy_workflows/starter/recipe.yaml +25 -0
  38. hepyy_workflows-0.1.0/src/hepyy_workflows/ui/__init__.py +5 -0
  39. hepyy_workflows-0.1.0/src/hepyy_workflows/ui/service.py +65 -0
  40. hepyy_workflows-0.1.0/src/hepyy_workflows/ui/tui.py +23 -0
  41. hepyy_workflows-0.1.0/src/hepyy_workflows/ui/web.py +103 -0
  42. hepyy_workflows-0.1.0/src/hepyy_workflows.egg-info/PKG-INFO +113 -0
  43. hepyy_workflows-0.1.0/src/hepyy_workflows.egg-info/SOURCES.txt +50 -0
  44. hepyy_workflows-0.1.0/src/hepyy_workflows.egg-info/dependency_links.txt +1 -0
  45. hepyy_workflows-0.1.0/src/hepyy_workflows.egg-info/entry_points.txt +2 -0
  46. hepyy_workflows-0.1.0/src/hepyy_workflows.egg-info/requires.txt +31 -0
  47. hepyy_workflows-0.1.0/src/hepyy_workflows.egg-info/top_level.txt +1 -0
  48. hepyy_workflows-0.1.0/tests/test_agent.py +56 -0
  49. hepyy_workflows-0.1.0/tests/test_analysis.py +642 -0
  50. hepyy_workflows-0.1.0/tests/test_foundation.py +164 -0
  51. hepyy_workflows-0.1.0/tests/test_processing.py +62 -0
  52. hepyy_workflows-0.1.0/tests/test_readers.py +668 -0
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: hepyy-workflows
3
+ Version: 0.1.0
4
+ Summary: Explicit, portable HEP data workflows
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: hepyy>=0.2.18
8
+ Requires-Dist: jsonschema>=4.23
9
+ Requires-Dist: numpy>=1.26
10
+ Requires-Dist: pyarrow>=18
11
+ Requires-Dist: PyYAML>=6
12
+ Provides-Extra: parquet
13
+ Provides-Extra: root
14
+ Requires-Dist: uproot>=5; extra == "root"
15
+ Requires-Dist: awkward>=2; extra == "root"
16
+ Provides-Extra: hdf5
17
+ Requires-Dist: h5py>=3; extra == "hdf5"
18
+ Provides-Extra: hepmc
19
+ Requires-Dist: hepyy>=0.2.18; extra == "hepmc"
20
+ Provides-Extra: pythia
21
+ Requires-Dist: hepyy>=0.2.18; extra == "pythia"
22
+ Provides-Extra: web
23
+ Requires-Dist: fastapi>=0.115; extra == "web"
24
+ Requires-Dist: uvicorn>=0.30; extra == "web"
25
+ Provides-Extra: tui
26
+ Requires-Dist: textual>=0.80; extra == "tui"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: ruff>=0.9; extra == "dev"
30
+
31
+ # hepyy-workflows
32
+
33
+ An explicit HEP workflow package. [SPECS.md](SPECS.md) defines the full project;
34
+ this checkout currently implements **milestones 1 through 6**. The package name is
35
+ `hepyy_workflows` and the CLI is `hwf`. Python 3.11+ is required.
36
+
37
+ The package provides Arrow dataset/source contracts, strict public reader and
38
+ recipe schemas, a starter project, and bounded inspect/draft/validate/preview
39
+ workflows for Parquet, ROOT TTrees, explicit HDF5 layouts, and HepMC3 ASCII.
40
+ Recipes now run generic views, selections, histograms and cutflows and export a
41
+ standalone file-analysis runner, self-contained reports, and local web/terminal
42
+ authoring tools.
43
+
44
+ ```bash
45
+ henv -n YOUR_ENV -x python -m pip install -e '.[dev]'
46
+ henv -n YOUR_ENV -x python -m pytest
47
+ ```
48
+
49
+ Create a starter project **from any directory** (choose a destination that does
50
+ not yet exist):
51
+
52
+ ```bash
53
+ henv -n YOUR_ENV -x hwf project init /path/to/my-analysis
54
+ cd /path/to/my-analysis
55
+ henv -n YOUR_ENV -x hwf schema validate reader reader.yaml --json
56
+ henv -n YOUR_ENV -x hwf schema validate recipe recipe.yaml --json
57
+ henv -n YOUR_ENV -x hwf reader inspect data/particles.parquet --json
58
+ henv -n YOUR_ENV -x hwf reader validate reader.yaml --input data/particles.parquet --json
59
+ henv -n YOUR_ENV -x hwf reader preview reader.yaml --input data/particles.parquet --events 2 --json
60
+ henv -n YOUR_ENV -x hwf recipe run recipe.yaml --input data/particles.parquet --output results --json
61
+ henv -n YOUR_ENV -x hwf recipe export recipe.yaml --input data/particles.parquet --output standalone --json
62
+ ```
63
+
64
+ The last command reads the starter project's sample file. See
65
+ [working-in-a-project.md](docs/working-in-a-project.md) for what the starter
66
+ files mean and [examples/readers](examples/readers/README.md) for each format.
67
+ For choosing event boundaries, collection paths, units and labels in an unfamiliar
68
+ file, see [FILE_FORMAT_README.md](FILE_FORMAT_README.md).
69
+ See [analysis.md](docs/analysis.md) for recipe operations, histogram products,
70
+ and standalone execution; the [Lund](examples/lund/README.md) and
71
+ [dilepton](examples/dilepton/README.md) examples are complete workflows.
72
+
73
+ Build an offline report from completed results, or inspect them remotely in a
74
+ terminal:
75
+
76
+ ```bash
77
+ henv -n YOUR_ENV -x hwf report results --output results.html --json
78
+ henv -n YOUR_ENV -x hwf tui results
79
+ henv -n YOUR_ENV -x hwf web recipe.yaml --port 8765
80
+ ```
81
+
82
+ The report is one HTML file with embedded data and scripts; it works from
83
+ `file://` without a server or network connection. The web editor listens on
84
+ localhost and validates edits through the same service as the CLI.
85
+
86
+ Agents can use the same operations through line-delimited JSON:
87
+
88
+ ```bash
89
+ printf '%s\n' '{"id":1,"method":"inspect_input","params":{"input":"data/particles.parquet"}}' \
90
+ | henv -n YOUR_ENV -x hwf agent serve
91
+ henv -n YOUR_ENV -x hwf reader scaffold my_reader --output src/my_reader
92
+ ```
93
+
94
+ See [agent-workflow.md](docs/agent-workflow.md) for the typed method contract
95
+ and the recommended inspect → draft → validate → preview → run → export flow.
96
+
97
+ Recipes may declare a visible Python preprocessing module. It receives the
98
+ ordered input paths and a Parquet output path, and its source and dependencies
99
+ are bundled by standalone export. See [processing.md](docs/processing.md).
100
+
101
+ `hwf schema validate` accepts `reader-draft`, `reader`, or `recipe`; failed
102
+ validation exits nonzero and reports stable diagnostic codes and YAML paths.
103
+ Paths passed to the CLI are resolved from your current directory; use absolute
104
+ paths when invoking it from elsewhere. A missing file reports `FILE_NOT_FOUND`.
105
+ An omitted momentum-field unit resolves to GeV/c and appears in `assumed_units`
106
+ in JSON output. Declare a different source unit explicitly when needed.
107
+ Schema files are available under `schemas/` and ship inside the wheel.
108
+
109
+ The package requires NumPy, PyArrow, JSON Schema and PyYAML. Pure histogramming does not import hepyy,
110
+ FastJet, PYTHIA, HepMC, uproot, h5py or a web server. Optional extras in
111
+ `pyproject.toml` reserve these dependency boundaries. For all milestone 2
112
+ readers, install `.[root,hdf5,hepmc]` in the analysis environment. See
113
+ [architecture.md](docs/architecture.md) and [provenance.md](docs/provenance.md).
@@ -0,0 +1,83 @@
1
+ # hepyy-workflows
2
+
3
+ An explicit HEP workflow package. [SPECS.md](SPECS.md) defines the full project;
4
+ this checkout currently implements **milestones 1 through 6**. The package name is
5
+ `hepyy_workflows` and the CLI is `hwf`. Python 3.11+ is required.
6
+
7
+ The package provides Arrow dataset/source contracts, strict public reader and
8
+ recipe schemas, a starter project, and bounded inspect/draft/validate/preview
9
+ workflows for Parquet, ROOT TTrees, explicit HDF5 layouts, and HepMC3 ASCII.
10
+ Recipes now run generic views, selections, histograms and cutflows and export a
11
+ standalone file-analysis runner, self-contained reports, and local web/terminal
12
+ authoring tools.
13
+
14
+ ```bash
15
+ henv -n YOUR_ENV -x python -m pip install -e '.[dev]'
16
+ henv -n YOUR_ENV -x python -m pytest
17
+ ```
18
+
19
+ Create a starter project **from any directory** (choose a destination that does
20
+ not yet exist):
21
+
22
+ ```bash
23
+ henv -n YOUR_ENV -x hwf project init /path/to/my-analysis
24
+ cd /path/to/my-analysis
25
+ henv -n YOUR_ENV -x hwf schema validate reader reader.yaml --json
26
+ henv -n YOUR_ENV -x hwf schema validate recipe recipe.yaml --json
27
+ henv -n YOUR_ENV -x hwf reader inspect data/particles.parquet --json
28
+ henv -n YOUR_ENV -x hwf reader validate reader.yaml --input data/particles.parquet --json
29
+ henv -n YOUR_ENV -x hwf reader preview reader.yaml --input data/particles.parquet --events 2 --json
30
+ henv -n YOUR_ENV -x hwf recipe run recipe.yaml --input data/particles.parquet --output results --json
31
+ henv -n YOUR_ENV -x hwf recipe export recipe.yaml --input data/particles.parquet --output standalone --json
32
+ ```
33
+
34
+ The last command reads the starter project's sample file. See
35
+ [working-in-a-project.md](docs/working-in-a-project.md) for what the starter
36
+ files mean and [examples/readers](examples/readers/README.md) for each format.
37
+ For choosing event boundaries, collection paths, units and labels in an unfamiliar
38
+ file, see [FILE_FORMAT_README.md](FILE_FORMAT_README.md).
39
+ See [analysis.md](docs/analysis.md) for recipe operations, histogram products,
40
+ and standalone execution; the [Lund](examples/lund/README.md) and
41
+ [dilepton](examples/dilepton/README.md) examples are complete workflows.
42
+
43
+ Build an offline report from completed results, or inspect them remotely in a
44
+ terminal:
45
+
46
+ ```bash
47
+ henv -n YOUR_ENV -x hwf report results --output results.html --json
48
+ henv -n YOUR_ENV -x hwf tui results
49
+ henv -n YOUR_ENV -x hwf web recipe.yaml --port 8765
50
+ ```
51
+
52
+ The report is one HTML file with embedded data and scripts; it works from
53
+ `file://` without a server or network connection. The web editor listens on
54
+ localhost and validates edits through the same service as the CLI.
55
+
56
+ Agents can use the same operations through line-delimited JSON:
57
+
58
+ ```bash
59
+ printf '%s\n' '{"id":1,"method":"inspect_input","params":{"input":"data/particles.parquet"}}' \
60
+ | henv -n YOUR_ENV -x hwf agent serve
61
+ henv -n YOUR_ENV -x hwf reader scaffold my_reader --output src/my_reader
62
+ ```
63
+
64
+ See [agent-workflow.md](docs/agent-workflow.md) for the typed method contract
65
+ and the recommended inspect → draft → validate → preview → run → export flow.
66
+
67
+ Recipes may declare a visible Python preprocessing module. It receives the
68
+ ordered input paths and a Parquet output path, and its source and dependencies
69
+ are bundled by standalone export. See [processing.md](docs/processing.md).
70
+
71
+ `hwf schema validate` accepts `reader-draft`, `reader`, or `recipe`; failed
72
+ validation exits nonzero and reports stable diagnostic codes and YAML paths.
73
+ Paths passed to the CLI are resolved from your current directory; use absolute
74
+ paths when invoking it from elsewhere. A missing file reports `FILE_NOT_FOUND`.
75
+ An omitted momentum-field unit resolves to GeV/c and appears in `assumed_units`
76
+ in JSON output. Declare a different source unit explicitly when needed.
77
+ Schema files are available under `schemas/` and ship inside the wheel.
78
+
79
+ The package requires NumPy, PyArrow, JSON Schema and PyYAML. Pure histogramming does not import hepyy,
80
+ FastJet, PYTHIA, HepMC, uproot, h5py or a web server. Optional extras in
81
+ `pyproject.toml` reserve these dependency boundaries. For all milestone 2
82
+ readers, install `.[root,hdf5,hepmc]` in the analysis environment. See
83
+ [architecture.md](docs/architecture.md) and [provenance.md](docs/provenance.md).
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hepyy-workflows"
7
+ version = "0.1.0"
8
+ description = "Explicit, portable HEP data workflows"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = ["hepyy>=0.2.18", "jsonschema>=4.23", "numpy>=1.26", "pyarrow>=18", "PyYAML>=6"]
12
+
13
+ [project.optional-dependencies]
14
+ parquet = []
15
+ root = ["uproot>=5", "awkward>=2"]
16
+ hdf5 = ["h5py>=3"]
17
+ hepmc = ["hepyy>=0.2.18"]
18
+ pythia = ["hepyy>=0.2.18"]
19
+ web = ["fastapi>=0.115", "uvicorn>=0.30"]
20
+ tui = ["textual>=0.80"]
21
+ dev = ["pytest>=8", "ruff>=0.9"]
22
+
23
+ [project.scripts]
24
+ hwf = "hepyy_workflows.cli:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ hepyy_workflows = ["schemas/*.json", "data/*.parquet", "starter/*.yaml", "starter/*.md"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+
35
+ [tool.ruff]
36
+ target-version = "py311"
37
+ line-length = 100
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "UP", "B"]
41
+
42
+ [tool.ruff.lint.per-file-ignores]
43
+ "src/hepyy_workflows/reporting/report.py" = ["E501"]
44
+ "src/hepyy_workflows/ui/web.py" = ["E501"]
45
+ "src/hepyy_workflows/agent/service.py" = ["E501"]
46
+ "src/hepyy_workflows/readers/scaffold.py" = ["E501"]
47
+ "tests/test_agent.py" = ["E501"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Source-neutral contracts and services for HEP workflows."""
2
+
3
+ __version__ = "0.1.0.dev0"
@@ -0,0 +1,5 @@
1
+ """Optional narrow agent integration."""
2
+
3
+ from .service import dispatch
4
+
5
+ __all__ = ["dispatch"]
@@ -0,0 +1,80 @@
1
+ """Stable JSON operations for agents and ordinary automation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from hepyy_workflows.analysis.runner import prepare_recipe, run_recipe
9
+ from hepyy_workflows.export.standalone import export_recipe
10
+ from hepyy_workflows.readers.scaffold import scaffold_reader
11
+ from hepyy_workflows.readers.service import (
12
+ draft_reader,
13
+ inspect_input,
14
+ preview_reader,
15
+ validate_reader,
16
+ write_draft,
17
+ )
18
+ from hepyy_workflows.reporting.report import build_report
19
+
20
+
21
+ def _paths(values: list[str] | None) -> list[Path]:
22
+ return [Path(value) for value in values or []]
23
+
24
+
25
+ def _error(error: Exception, code: str = "AGENT_FAILED") -> dict[str, Any]:
26
+ return {
27
+ "valid": False,
28
+ "diagnostics": [
29
+ {
30
+ "code": getattr(error, "code", code),
31
+ "path": getattr(error, "path", "/"),
32
+ "message": str(error),
33
+ }
34
+ ],
35
+ }
36
+
37
+
38
+ def dispatch(method: str, params: dict[str, Any]) -> dict[str, Any]:
39
+ try:
40
+ if method == "inspect_input":
41
+ return inspect_input(Path(params["input"]))
42
+ if method == "draft_reader":
43
+ if "output" in params:
44
+ return write_draft(Path(params["input"]), Path(params["output"]))
45
+ draft, inspection = draft_reader(Path(params["input"]))
46
+ return {**inspection, "draft": draft}
47
+ if method == "validate_reader":
48
+ return validate_reader(Path(params["reader"]), _paths(params.get("inputs")))
49
+ if method == "preview_reader":
50
+ return preview_reader(
51
+ Path(params["reader"]), _paths(params.get("inputs")), int(params.get("events", 10))
52
+ )
53
+ if method == "validate_recipe":
54
+ _, _, prepared = prepare_recipe(Path(params["recipe"]), params.get("inputs"))
55
+ return {
56
+ "valid": True,
57
+ "inputs": {k: [str(p) for p in v] for k, v in prepared["paths"].items()},
58
+ }
59
+ if method == "run_analysis":
60
+ return run_recipe(
61
+ Path(params["recipe"]),
62
+ Path(params["output"]),
63
+ params.get("inputs"),
64
+ params.get("max_events"),
65
+ )
66
+ if method == "export_standalone":
67
+ return export_recipe(
68
+ Path(params["recipe"]), Path(params["output"]), params.get("inputs")
69
+ )
70
+ if method == "build_report":
71
+ return build_report(Path(params["results"]), Path(params["output"]))
72
+ if method == "scaffold_reader":
73
+ return scaffold_reader(params["name"], Path(params["output"]))
74
+ if method == "get_status":
75
+ return {"valid": True, "status": "idle", "diagnostics": []}
76
+ if method == "cancel_run":
77
+ return _error(ValueError("no asynchronous run is active"), "NO_ACTIVE_RUN")
78
+ return _error(ValueError(f"unknown method: {method}"), "METHOD_UNKNOWN")
79
+ except Exception as error:
80
+ return _error(error)
@@ -0,0 +1,54 @@
1
+ """Line-delimited JSON agent interface; no shell or arbitrary code endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from .service import dispatch
9
+
10
+ _METHODS = (
11
+ "inspect_input",
12
+ "draft_reader",
13
+ "validate_reader",
14
+ "preview_reader",
15
+ "validate_recipe",
16
+ "run_analysis",
17
+ "export_standalone",
18
+ "build_report",
19
+ "scaffold_reader",
20
+ "get_status",
21
+ "cancel_run",
22
+ )
23
+
24
+
25
+ def serve_stdio() -> int:
26
+ for line in sys.stdin:
27
+ if not line.strip():
28
+ continue
29
+ try:
30
+ request = json.loads(line)
31
+ if not isinstance(request, dict) or not isinstance(request.get("method"), str):
32
+ raise ValueError("request needs a method")
33
+ method = request["method"]
34
+ if method == "initialize":
35
+ result = {
36
+ "protocolVersion": "2025-03-26",
37
+ "serverInfo": {"name": "hwf", "version": "1"},
38
+ }
39
+ elif method == "tools/list":
40
+ result = {
41
+ "tools": [
42
+ {"name": name, "description": f"HWF {name} operation"} for name in _METHODS
43
+ ]
44
+ }
45
+ elif method == "tools/call":
46
+ params = request.get("params", {})
47
+ result = dispatch(params["name"], params.get("arguments", {}))
48
+ else:
49
+ result = dispatch(method, request.get("params", {}))
50
+ response = {"id": request.get("id"), "result": result}
51
+ except Exception as error:
52
+ response = {"id": None, "error": {"code": "REQUEST_INVALID", "message": str(error)}}
53
+ print(json.dumps(response, default=str), flush=True)
54
+ return 0
@@ -0,0 +1,5 @@
1
+ """Generic recipe analysis services."""
2
+
3
+ from .runner import RecipeError, prepare_recipe, run_recipe
4
+
5
+ __all__ = ["RecipeError", "prepare_recipe", "run_recipe"]
@@ -0,0 +1,184 @@
1
+ """Small, side-effect-free expression evaluator for recipe values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import math
7
+ import operator
8
+ from functools import lru_cache
9
+ from typing import Any
10
+
11
+
12
+ class ExpressionError(ValueError):
13
+ pass
14
+
15
+
16
+ def wrapped_angle(value: float) -> float:
17
+ return math.atan2(math.sin(value), math.cos(value))
18
+
19
+
20
+ _FUNCTIONS = {
21
+ "abs": abs,
22
+ "sqrt": math.sqrt,
23
+ "log": math.log,
24
+ "log10": math.log10,
25
+ "sin": math.sin,
26
+ "cos": math.cos,
27
+ "tan": math.tan,
28
+ "atan2": math.atan2,
29
+ "hypot": math.hypot,
30
+ "wrapped_angle": wrapped_angle,
31
+ "isfinite": math.isfinite,
32
+ "min": min,
33
+ "max": max,
34
+ }
35
+ _BINARY = {
36
+ ast.Add: operator.add,
37
+ ast.Sub: operator.sub,
38
+ ast.Mult: operator.mul,
39
+ ast.Div: operator.truediv,
40
+ ast.Pow: math.pow,
41
+ ast.Mod: operator.mod,
42
+ }
43
+ _COMPARE = {
44
+ ast.Eq: operator.eq,
45
+ ast.NotEq: operator.ne,
46
+ ast.Lt: operator.lt,
47
+ ast.LtE: operator.le,
48
+ ast.Gt: operator.gt,
49
+ ast.GtE: operator.ge,
50
+ ast.Is: operator.is_,
51
+ ast.IsNot: operator.is_not,
52
+ }
53
+
54
+
55
+ @lru_cache(maxsize=1024)
56
+ def compile_expression(source: str) -> ast.Expression:
57
+ try:
58
+ tree = ast.parse(source, mode="eval")
59
+ except SyntaxError as exc:
60
+ raise ExpressionError(f"invalid expression {source!r}: {exc.msg}") from exc
61
+ _check(tree.body)
62
+ return tree
63
+
64
+
65
+ def referenced_names(source: str) -> set[str]:
66
+ """Return field/parameter roots, excluding whitelisted function names."""
67
+
68
+ tree = compile_expression(source)
69
+ names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
70
+ calls = {
71
+ node.func.id
72
+ for node in ast.walk(tree)
73
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
74
+ }
75
+ return names - calls
76
+
77
+
78
+ def _path(node: ast.AST) -> str | None:
79
+ if isinstance(node, ast.Name):
80
+ return node.id
81
+ if isinstance(node, ast.Attribute):
82
+ parent = _path(node.value)
83
+ return None if parent is None else parent + "." + node.attr
84
+ return None
85
+
86
+
87
+ def _check(node: ast.AST) -> None:
88
+ if isinstance(node, ast.Constant):
89
+ if not isinstance(node.value, (int, float, str, bool, type(None))):
90
+ raise ExpressionError("unsupported literal")
91
+ elif isinstance(node, (ast.Name, ast.Attribute)):
92
+ if _path(node) is None or any(part.startswith("_") for part in _path(node).split(".")):
93
+ raise ExpressionError("private or dynamic field access is forbidden")
94
+ elif isinstance(node, ast.BinOp) and type(node.op) in _BINARY:
95
+ _check(node.left)
96
+ _check(node.right)
97
+ elif isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd, ast.Not)):
98
+ _check(node.operand)
99
+ elif isinstance(node, ast.BoolOp) and isinstance(node.op, (ast.And, ast.Or)):
100
+ for value in node.values:
101
+ _check(value)
102
+ elif isinstance(node, ast.Compare) and all(type(op) in _COMPARE for op in node.ops):
103
+ _check(node.left)
104
+ for item in node.comparators:
105
+ _check(item)
106
+ elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
107
+ if node.func.id not in _FUNCTIONS or node.keywords:
108
+ raise ExpressionError("function is not allowed")
109
+ for item in node.args:
110
+ _check(item)
111
+ else:
112
+ raise ExpressionError(f"unsupported expression element: {type(node).__name__}")
113
+
114
+
115
+ def field(context: dict, path: str) -> Any:
116
+ if path in context:
117
+ return context[path]
118
+ parts = path.split(".")
119
+ for start in range(len(parts), 0, -1):
120
+ prefix = ".".join(parts[:start])
121
+ if prefix in context:
122
+ value = context[prefix]
123
+ for part in parts[start:]:
124
+ if not isinstance(value, dict):
125
+ return None
126
+ value = value.get(part)
127
+ return value
128
+ return None
129
+
130
+
131
+ def evaluate(source: str, context: dict) -> Any:
132
+ tree = compile_expression(source)
133
+
134
+ def visit(node: ast.AST) -> Any:
135
+ if isinstance(node, ast.Constant):
136
+ return node.value
137
+ if isinstance(node, (ast.Name, ast.Attribute)):
138
+ return field(context, _path(node))
139
+ if isinstance(node, ast.BinOp):
140
+ left, right = visit(node.left), visit(node.right)
141
+ if left is None or right is None:
142
+ return None
143
+ if not isinstance(left, (int, float)) or not isinstance(right, (int, float)):
144
+ return None
145
+ try:
146
+ value = _BINARY[type(node.op)](left, right)
147
+ except (ArithmeticError, OverflowError, TypeError, ValueError):
148
+ return None
149
+ return value if not isinstance(value, float) or math.isfinite(value) else None
150
+ if isinstance(node, ast.UnaryOp):
151
+ value = visit(node.operand)
152
+ if isinstance(node.op, ast.Not):
153
+ return not bool(value)
154
+ return None if value is None else (-value if isinstance(node.op, ast.USub) else +value)
155
+ if isinstance(node, ast.BoolOp):
156
+ if isinstance(node.op, ast.And):
157
+ return all(bool(visit(item)) for item in node.values)
158
+ return any(bool(visit(item)) for item in node.values)
159
+ if isinstance(node, ast.Compare):
160
+ left = visit(node.left)
161
+ for op, right_node in zip(node.ops, node.comparators, strict=True):
162
+ right = visit(right_node)
163
+ if left is None or right is None:
164
+ if not isinstance(op, (ast.Is, ast.IsNot, ast.Eq, ast.NotEq)):
165
+ return False
166
+ try:
167
+ if not _COMPARE[type(op)](left, right):
168
+ return False
169
+ except TypeError:
170
+ return False
171
+ left = right
172
+ return True
173
+ if isinstance(node, ast.Call):
174
+ args = [visit(item) for item in node.args]
175
+ if any(item is None for item in args):
176
+ return None
177
+ try:
178
+ value = _FUNCTIONS[node.func.id](*args)
179
+ except (ArithmeticError, OverflowError, TypeError, ValueError):
180
+ return None
181
+ return value if not isinstance(value, float) or math.isfinite(value) else None
182
+ raise AssertionError("expression was not checked")
183
+
184
+ return visit(tree.body)