physverify 0.5.0__tar.gz → 0.6.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 (67) hide show
  1. {physverify-0.5.0/src/physverify.egg-info → physverify-0.6.0}/PKG-INFO +7 -4
  2. {physverify-0.5.0 → physverify-0.6.0}/README.md +5 -3
  3. {physverify-0.5.0 → physverify-0.6.0}/pyproject.toml +2 -2
  4. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/__init__.py +1 -1
  5. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/agent.py +12 -3
  6. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/cli.py +20 -6
  7. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/constants.py +3 -1
  8. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/lint.py +18 -3
  9. physverify-0.6.0/src/physverify/probes.py +1057 -0
  10. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/scan.py +52 -4
  11. {physverify-0.5.0 → physverify-0.6.0/src/physverify.egg-info}/PKG-INFO +7 -4
  12. {physverify-0.5.0 → physverify-0.6.0}/src/physverify.egg-info/SOURCES.txt +2 -0
  13. {physverify-0.5.0 → physverify-0.6.0}/src/physverify.egg-info/requires.txt +3 -0
  14. {physverify-0.5.0 → physverify-0.6.0}/tests/test_agent.py +16 -0
  15. physverify-0.6.0/tests/test_probes.py +314 -0
  16. {physverify-0.5.0 → physverify-0.6.0}/tests/test_scan.py +71 -0
  17. {physverify-0.5.0 → physverify-0.6.0}/tests/test_versions.py +5 -0
  18. {physverify-0.5.0 → physverify-0.6.0}/LICENSE +0 -0
  19. {physverify-0.5.0 → physverify-0.6.0}/setup.cfg +0 -0
  20. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/__main__.py +0 -0
  21. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/butcher.py +0 -0
  22. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/checks.py +0 -0
  23. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/demo.py +0 -0
  24. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/identities.py +0 -0
  25. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/loaders.py +0 -0
  26. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/mms.py +0 -0
  27. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/order.py +0 -0
  28. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/pytest_plugin.py +0 -0
  29. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/references.py +0 -0
  30. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/report.py +0 -0
  31. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/scaffold.py +0 -0
  32. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/smoke.py +0 -0
  33. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/steppers.py +0 -0
  34. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/stochastic/__init__.py +0 -0
  35. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/stochastic/calibrate.py +0 -0
  36. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/stochastic/checks.py +0 -0
  37. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/stochastic/exact.py +0 -0
  38. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/stochastic/ising.py +0 -0
  39. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/stochastic/reference.py +0 -0
  40. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/stochastic/sampler.py +0 -0
  41. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/stochastic/stats.py +0 -0
  42. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/studies.py +0 -0
  43. {physverify-0.5.0 → physverify-0.6.0}/src/physverify/symbolic.py +0 -0
  44. {physverify-0.5.0 → physverify-0.6.0}/src/physverify.egg-info/dependency_links.txt +0 -0
  45. {physverify-0.5.0 → physverify-0.6.0}/src/physverify.egg-info/entry_points.txt +0 -0
  46. {physverify-0.5.0 → physverify-0.6.0}/src/physverify.egg-info/top_level.txt +0 -0
  47. {physverify-0.5.0 → physverify-0.6.0}/tests/test_awkward_input.py +0 -0
  48. {physverify-0.5.0 → physverify-0.6.0}/tests/test_batch7_lessons.py +0 -0
  49. {physverify-0.5.0 → physverify-0.6.0}/tests/test_batch8_lessons.py +0 -0
  50. {physverify-0.5.0 → physverify-0.6.0}/tests/test_butcher_properties.py +0 -0
  51. {physverify-0.5.0 → physverify-0.6.0}/tests/test_cli.py +0 -0
  52. {physverify-0.5.0 → physverify-0.6.0}/tests/test_corpus_lessons.py +0 -0
  53. {physverify-0.5.0 → physverify-0.6.0}/tests/test_docs_examples.py +0 -0
  54. {physverify-0.5.0 → physverify-0.6.0}/tests/test_figures.py +0 -0
  55. {physverify-0.5.0 → physverify-0.6.0}/tests/test_ising.py +0 -0
  56. {physverify-0.5.0 → physverify-0.6.0}/tests/test_method_bugs.py +0 -0
  57. {physverify-0.5.0 → physverify-0.6.0}/tests/test_mms.py +0 -0
  58. {physverify-0.5.0 → physverify-0.6.0}/tests/test_new_problems.py +0 -0
  59. {physverify-0.5.0 → physverify-0.6.0}/tests/test_order.py +0 -0
  60. {physverify-0.5.0 → physverify-0.6.0}/tests/test_properties.py +0 -0
  61. {physverify-0.5.0 → physverify-0.6.0}/tests/test_real_projects.py +0 -0
  62. {physverify-0.5.0 → physverify-0.6.0}/tests/test_reference_problems.py +0 -0
  63. {physverify-0.5.0 → physverify-0.6.0}/tests/test_references.py +0 -0
  64. {physverify-0.5.0 → physverify-0.6.0}/tests/test_scaffold.py +0 -0
  65. {physverify-0.5.0 → physverify-0.6.0}/tests/test_seeded_bugs.py +0 -0
  66. {physverify-0.5.0 → physverify-0.6.0}/tests/test_studies.py +0 -0
  67. {physverify-0.5.0 → physverify-0.6.0}/tests/test_v040_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: physverify
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Verification checks for physics simulation code: manufactured solutions, order-of-accuracy studies and a seeded-bug benchmark.
5
5
  Author: Ram Chandra Giri
6
6
  License-Expression: MIT
@@ -27,6 +27,7 @@ Requires-Python: >=3.10
27
27
  Description-Content-Type: text/markdown
28
28
  License-File: LICENSE
29
29
  Requires-Dist: numpy>=1.24
30
+ Requires-Dist: tomli>=1.1; python_version < "3.11"
30
31
  Provides-Extra: symbolic
31
32
  Requires-Dist: sympy>=1.12; extra == "symbolic"
32
33
  Provides-Extra: dev
@@ -45,7 +46,7 @@ Dynamic: license-file
45
46
 
46
47
  **Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
47
48
 
48
- physverify is actively evolving (v0.5.0), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
49
+ physverify is actively evolving (v0.6.0), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
49
50
 
50
51
  ![The check loop: your solver and an independent description of the physics go in; physverify runs the solver at several resolutions, measures the error and reports a verdict with a reason](https://raw.githubusercontent.com/rcgiri-physics/physverify/main/docs/images/check-loop.svg)
51
52
 
@@ -72,7 +73,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
72
73
  A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
73
74
 
74
75
  ```yaml
75
- - uses: rcgiri-physics/physverify@v0.5.0
76
+ - uses: rcgiri-physics/physverify@v0.6.0
76
77
  with:
77
78
  specs: tests/verify_*.py
78
79
  ```
@@ -89,4 +90,6 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
89
90
 
90
91
  ## Contributing, citing, licence
91
92
 
92
- Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
93
+ Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). The methods are standard and other tools do parts of this, some better (NodePy for Runge-Kutta analysis); see [related work and credits](https://rcgiri-physics.github.io/physverify/related-work/). MIT licence, copyright (c) 2026 Ram Chandra Giri.
94
+
95
+ <!-- mcp-name: io.github.rcgiri-physics/physverify -->
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
6
6
 
7
- physverify is actively evolving (v0.5.0), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
7
+ physverify is actively evolving (v0.6.0), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
8
8
 
9
9
  ![The check loop: your solver and an independent description of the physics go in; physverify runs the solver at several resolutions, measures the error and reports a verdict with a reason](https://raw.githubusercontent.com/rcgiri-physics/physverify/main/docs/images/check-loop.svg)
10
10
 
@@ -31,7 +31,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
31
31
  A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
32
32
 
33
33
  ```yaml
34
- - uses: rcgiri-physics/physverify@v0.5.0
34
+ - uses: rcgiri-physics/physverify@v0.6.0
35
35
  with:
36
36
  specs: tests/verify_*.py
37
37
  ```
@@ -48,4 +48,6 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
48
48
 
49
49
  ## Contributing, citing, licence
50
50
 
51
- Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
51
+ Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). The methods are standard and other tools do parts of this, some better (NodePy for Runge-Kutta analysis); see [related work and credits](https://rcgiri-physics.github.io/physverify/related-work/). MIT licence, copyright (c) 2026 Ram Chandra Giri.
52
+
53
+ <!-- mcp-name: io.github.rcgiri-physics/physverify -->
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "physverify"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Verification checks for physics simulation code: manufactured solutions, order-of-accuracy studies and a seeded-bug benchmark."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -28,7 +28,7 @@ classifiers = [
28
28
  "Topic :: Software Development :: Testing",
29
29
  "Development Status :: 3 - Alpha",
30
30
  ]
31
- dependencies = ["numpy>=1.24"]
31
+ dependencies = ["numpy>=1.24", "tomli>=1.1; python_version < '3.11'"]
32
32
 
33
33
  [project.urls]
34
34
  Homepage = "https://github.com/rcgiri-physics/physverify"
@@ -6,7 +6,7 @@ from .order import check_order, fit_order, pairwise_orders
6
6
  from .order import check_exact
7
7
  from .studies import invariant_study, ode_checks, patch_study, reference_study
8
8
 
9
- __version__ = "0.5.0"
9
+ __version__ = "0.6.0"
10
10
 
11
11
  __all__ = [
12
12
  "Check",
@@ -49,13 +49,18 @@ def find_specs(root: Path) -> list[Path]:
49
49
  pyproject = root / "pyproject.toml"
50
50
  if pyproject.is_file():
51
51
  try:
52
- import tomllib
52
+ try:
53
+ import tomllib
54
+ except ImportError: # Python 3.10
55
+ import tomli as tomllib
53
56
 
54
57
  config = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {}).get("physverify", {})
55
58
  except Exception:
56
59
  config = {}
57
60
  if config.get("specs"):
58
- return [(root / s).resolve() for s in config["specs"]]
61
+ # Entries that leave the root are dropped (GCVE-110-VULN-PYPI-2026-000391).
62
+ inside = root.resolve()
63
+ return [p for p in ((root / s).resolve() for s in config["specs"]) if p.is_relative_to(inside)]
59
64
  specs = []
60
65
  for path in sorted(root.rglob("verify_*.py")):
61
66
  parts = path.relative_to(root).parts[:-1]
@@ -111,8 +116,12 @@ def hook_main(stdin=None, stderr=None) -> int:
111
116
  edited = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
112
117
  if edited and not edited.endswith(".py"):
113
118
  return 0
114
- cwd = Path(event.get("cwd") or ".")
119
+ cwd = Path(event.get("cwd") or ".").resolve()
120
+ if edited and not Path(edited).resolve().is_relative_to(cwd):
121
+ return 0 # an edit outside the session folder must not run that folder's code (GCVE-110-VULN-PYPI-2026-000391)
115
122
  root = find_project_root(Path(edited).parent if edited else cwd)
123
+ if not root.is_relative_to(cwd):
124
+ root = cwd # never climb above the session folder
116
125
  specs = find_specs(root)
117
126
  if not specs:
118
127
  return 0
@@ -125,17 +125,24 @@ def _cmd_smoke(args) -> int:
125
125
  def _cmd_scan(args) -> int:
126
126
  import json
127
127
 
128
+ from .probes import DEFAULT_PROBES, PROBES, probe
128
129
  from .scan import STAGES, WARN, scan, to_dict
129
130
 
130
- stages = args.only.split(",") if args.only else STAGES
131
- unknown = set(stages) - set(STAGES)
131
+ known = STAGES + PROBES if args.run else STAGES
132
+ stages = args.only.split(",") if args.only else (STAGES + DEFAULT_PROBES if args.run else STAGES)
133
+ unknown = set(stages) - set(known)
132
134
  if unknown:
133
- print(f"physverify: unknown stage {', '.join(sorted(unknown))}; choose from {', '.join(STAGES)}", file=sys.stderr)
135
+ extra = "; the probes need --run" if unknown <= set(PROBES) else ""
136
+ print(f"physverify: unknown stage {', '.join(sorted(unknown))}; choose from {', '.join(known)}{extra}", file=sys.stderr)
134
137
  return 2
135
138
  if not Path(args.path).exists():
136
139
  print(f"physverify: {args.path} does not exist", file=sys.stderr)
137
140
  return 2
138
- findings, unparsed = scan(args.path, stages=stages)
141
+ findings, unparsed = scan(args.path, stages=[s for s in stages if s in STAGES])
142
+ probes = [s for s in stages if s in PROBES]
143
+ if probes:
144
+ print("physverify: --run calls the code; use it only on code you trust or inside the sandbox", file=sys.stderr)
145
+ findings += probe(args.path, probes=probes, timeout=args.timeout)
139
146
  if args.json:
140
147
  text = json.dumps(to_dict(findings, unparsed), indent=1)
141
148
  if args.json == "-":
@@ -148,8 +155,11 @@ def _cmd_scan(args) -> int:
148
155
  for u in unparsed:
149
156
  print(f"NOTE parse {u}")
150
157
  warn = sum(f.level == WARN for f in findings)
151
- print(f"{warn} warnings, {len(findings) - warn} notes, {len(unparsed)} files not parsed"
152
- " (the scan reads the code and runs nothing; a clean scan means only that these patterns were not found)")
158
+ skip = sum(f.level == "SKIP" for f in findings)
159
+ ran = (f", {skip} skipped (not passed)" if probes else "")
160
+ how = "the probes called the code with small inputs" if probes else "the scan reads the code and runs nothing"
161
+ print(f"{warn} warnings, {len(findings) - warn - skip} notes{ran}, {len(unparsed)} files not parsed"
162
+ f" ({how}; a clean scan means only that these patterns were not found)")
153
163
  return 1 if any(f.level == WARN for f in findings) else 0
154
164
 
155
165
 
@@ -204,6 +214,10 @@ def build_parser() -> argparse.ArgumentParser:
204
214
  sc.add_argument("path", help="a Python file or a folder")
205
215
  sc.add_argument("--only", metavar="STAGES", help="comma-separated stages: constants,tables,grid,pairs,names,api,literals,units (default all)")
206
216
  sc.add_argument("--json", metavar="FILE", help="write findings as JSON to FILE, or to stdout with -")
217
+ sc.add_argument("--run", action="store_true",
218
+ help="also run the probes smoke and branches, which CALL the code (trusted code or the sandbox only); "
219
+ "the experimental probes edges,deadargs,roundtrip,siblings,known,monotone with --only")
220
+ sc.add_argument("--timeout", type=float, default=60.0, help="with --run: seconds per script or file of functions (default 60)")
207
221
  sc.set_defaults(func=_cmd_scan)
208
222
 
209
223
  r = sub.add_parser("references", help="list the exact reference solutions in physverify.references")
@@ -32,11 +32,13 @@ KNOWN = {
32
32
  "e (charge)": ((1.602176634e-19,), "C", "exact: 1.602176634e-19"),
33
33
  "m_e": ((9.1093837015e-31, 9.1093837139e-31), "kg", "CODATA 2018: 9.1093837015e-31, 2022: 9.1093837139e-31"),
34
34
  "m_p": ((1.67262192369e-27, 1.67262192595e-27), "kg", "CODATA 2018: 1.67262192369e-27, 2022: 1.67262192595e-27"),
35
+ "m_n": ((1.67492749804e-27, 1.67492750056e-27), "kg", "CODATA 2018: 1.67492749804e-27, 2022: 1.67492750056e-27"),
36
+ "atomic mass unit": ((1.66053906660e-27, 1.66053906892e-27), "kg", "CODATA 2018: 1.66053906660e-27, 2022: 1.66053906892e-27"),
35
37
  "epsilon_0": ((8.8541878128e-12,), "F/m", "8.8541878128e-12"),
36
38
  "T_c (2D Ising)": ((2 / math.log(1 + math.sqrt(2)),), "J/k_B", "2 / ln(1 + sqrt 2) = 2.269185314"),
37
39
  # exact by definition (SI 2019, IAU 2012, international units)
38
40
  "N_A": ((6.02214076e23,), "1/mol", "exact: 6.02214076e23"),
39
- "R (gas constant)": ((8.314462618,), "J/(mol K)", "N_A k_B = 8.314462618..."),
41
+ "R (gas constant)": ((8.31446261815324,), "J/(mol K)", "exact: N_A k_B = 8.31446261815324"),
40
42
  "sigma (Stefan-Boltzmann)": ((5.670374419e-8,), "W m^-2 K^-4", "5.670374419e-8"),
41
43
  "astronomical unit": ((149597870700.0, 149597870.7, 1.495978707e13), "m, km or cm", "exact: 149597870700 m (IAU 2012)"),
42
44
  "light year": ((9460730472580800.0,), "m", "exact: 9460730472580800 m (Julian year)"),
@@ -129,6 +129,10 @@ def _annotation_nodes(tree) -> set[int]:
129
129
  return {id(m) for r in roots for m in ast.walk(r)}
130
130
 
131
131
 
132
+ # a file with this many undefined names, none of them a usual module alias, is treated as getting them at run time
133
+ _INJECTED_MIN = 6
134
+ _COMMON_ALIASES = {"np", "plt", "pd", "sp", "scipy", "numpy", "math", "os", "sys", "re", "json", "time", "random",
135
+ "torch", "jnp", "jax", "tf", "mpl", "sns", "nx", "integrate", "interpolate", "optimize", "linalg"}
132
136
  _NOQA = re.compile(r"#\s*noqa(?::[^#]*\bF821\b|\s*$|\s*#)", re.I)
133
137
 
134
138
 
@@ -155,6 +159,14 @@ def scan_names(path: Path, tree, rel: str, source: str = "") -> list[Finding]:
155
159
  continue # annotations are not evaluated with `from __future__ import annotations` (or from Python 3.14)
156
160
  unknown.setdefault(n.id, []).append(n.lineno)
157
161
  out = []
162
+ if len(unknown) >= _INJECTED_MIN and not unknown.keys() & _COMMON_ALIASES:
163
+ # many names and none of them a forgotten import: the file most likely gets them at run time
164
+ # (a host program such as SpaceClaim, or a library that defines names in the caller's namespace)
165
+ first = min(lines[0] for lines in unknown.values())
166
+ shown = ", ".join(sorted(unknown)[:8]) + (", ..." if len(unknown) > 8 else "")
167
+ return [Finding("names", NOTE, rel, first,
168
+ f"{len(unknown)} names are used but never defined or imported ({shown}); so many usually means they"
169
+ " are provided at run time by a host program or a library, so they are not reported one by one")]
158
170
  for name, lines in sorted(unknown.items(), key=lambda kv: kv[1][0]):
159
171
  close = [k for k in known if k != name and len(k) > 2 and _one_edit(name, k)]
160
172
  hint = f" (did you mean {close[0]}?)" if close else ""
@@ -225,7 +237,8 @@ _SCIPY = {
225
237
  "cumtrapz": "scipy.integrate.cumulative_trapezoid", "romb": None},
226
238
  "scipy.misc": {"*": "scipy.datasets, scipy.special or numpy (scipy.misc was removed)"},
227
239
  }
228
- _PY_REMOVED = {"imp": "importlib (removed in Python 3.12)", "distutils": "setuptools or sysconfig (removed in Python 3.12)",
240
+ _SHIMMED = {"distutils"} # still importable wherever setuptools is installed, so only a NOTE
241
+ _PY_REMOVED = {"imp": "importlib (removed in Python 3.12)", "distutils": "setuptools or sysconfig (removed from the standard library in Python 3.12; setuptools still provides it)",
229
242
  "asynchat": "asyncio (removed in Python 3.12)", "asyncore": "asyncio (removed in Python 3.12)",
230
243
  "smtpd": "aiosmtpd (removed in Python 3.12)"}
231
244
 
@@ -281,12 +294,14 @@ def scan_api(path: Path, tree, rel: str) -> list[Finding]:
281
294
  out.append(Finding("api", WARN, rel, n.lineno, f"{n.module}.{a.name} has been removed from SciPy"
282
295
  + (f"; use {new}" if new else "")))
283
296
  if n.module.split(".")[0] in _PY_REMOVED:
284
- out.append(Finding("api", WARN, rel, n.lineno, f"{n.module}: use {_PY_REMOVED[n.module.split('.')[0]]}"))
297
+ top = n.module.split(".")[0]
298
+ out.append(Finding("api", NOTE if top in _SHIMMED else WARN, rel, n.lineno, f"{n.module}: use {_PY_REMOVED[top]}"))
285
299
  if isinstance(n, ast.Import):
286
300
  for a in n.names:
287
301
  top = a.name.split(".")[0]
288
302
  if top in _PY_REMOVED:
289
- out.append(Finding("api", WARN, rel, n.lineno, f"import {a.name}: use {_PY_REMOVED[top]}"))
303
+ out.append(Finding("api", NOTE if top in _SHIMMED else WARN, rel, n.lineno,
304
+ f"import {a.name}: use {_PY_REMOVED[top]}"))
290
305
  if a.name == "scipy.misc":
291
306
  out.append(Finding("api", WARN, rel, n.lineno, "scipy.misc has been removed from SciPy"))
292
307
  if isinstance(n, ast.Attribute) and n.attr in ("simps", "cumtrapz") and isinstance(n.value, ast.Attribute) \