unitforge 0.3.9__tar.gz → 0.3.11__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 (73) hide show
  1. {unitforge-0.3.9 → unitforge-0.3.11}/.gitignore +1 -0
  2. unitforge-0.3.11/.gitlab-ci.yml +87 -0
  3. {unitforge-0.3.9 → unitforge-0.3.11}/Cargo.lock +9 -2
  4. {unitforge-0.3.9 → unitforge-0.3.11}/Cargo.toml +5 -4
  5. {unitforge-0.3.9 → unitforge-0.3.11}/PKG-INFO +3 -1
  6. {unitforge-0.3.9 → unitforge-0.3.11}/pyproject.toml +2 -0
  7. unitforge-0.3.11/python/unitforge/__init__.py +5 -0
  8. unitforge-0.3.11/scripts/generate_python_stubs.py +502 -0
  9. {unitforge-0.3.9 → unitforge-0.3.11}/src/impl_macros.rs +1 -1
  10. {unitforge-0.3.9 → unitforge-0.3.11}/src/lib.rs +4 -4
  11. {unitforge-0.3.9 → unitforge-0.3.11}/src/relations.rs +2 -0
  12. {unitforge-0.3.9 → unitforge-0.3.11}/src/small_linalg/bindings/matrix3_py.template.rs +1 -1
  13. {unitforge-0.3.9 → unitforge-0.3.11}/src/small_linalg/bindings/vector3_py.template.rs +29 -6
  14. {unitforge-0.3.9 → unitforge-0.3.11}/tests/test_small_linalg.py +14 -1
  15. unitforge-0.3.11/tests/typecheck/mypy_smoke.py +48 -0
  16. unitforge-0.3.11/unitforge_macros/Cargo.toml +15 -0
  17. unitforge-0.3.9/build.rs → unitforge-0.3.11/unitforge_macros/src/lib.rs +208 -94
  18. unitforge-0.3.9/.gitlab-ci.yml +0 -49
  19. {unitforge-0.3.9 → unitforge-0.3.11}/Readme.md +0 -0
  20. {unitforge-0.3.9 → unitforge-0.3.11}/Readme_py.md +0 -0
  21. {unitforge-0.3.9 → unitforge-0.3.11}/src/prelude.rs +0 -0
  22. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/acceleration.rs +0 -0
  23. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/angle.rs +0 -0
  24. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/angular_acceleration.rs +0 -0
  25. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/angular_velocity.rs +0 -0
  26. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/area.rs +0 -0
  27. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/area_of_moment.rs +0 -0
  28. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/charge.rs +0 -0
  29. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/compliance.rs +0 -0
  30. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/density.rs +0 -0
  31. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/distance.rs +0 -0
  32. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/force.rs +0 -0
  33. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/force_area.rs +0 -0
  34. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/force_distance.rs +0 -0
  35. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/force_div_distance_power_four.rs +0 -0
  36. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/force_per_volume.rs +0 -0
  37. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/force_stress.rs +0 -0
  38. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/force_volume.rs +0 -0
  39. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/inverse_area.rs +0 -0
  40. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/inverse_distance.rs +0 -0
  41. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/inverse_stress.rs +0 -0
  42. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/mass.rs +0 -0
  43. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/mass_div_time_squared_volume.rs +0 -0
  44. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/mass_per_distance.rs +0 -0
  45. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/rotational_stiffness.rs +0 -0
  46. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/stiffness.rs +0 -0
  47. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/strain.rs +0 -0
  48. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/stress.rs +0 -0
  49. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/stress_squared.rs +0 -0
  50. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/time.rs +0 -0
  51. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/velocity.rs +0 -0
  52. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/velocity_squared.rs +0 -0
  53. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/voltage.rs +0 -0
  54. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities/volume.rs +0 -0
  55. {unitforge-0.3.9 → unitforge-0.3.11}/src/quantities.template.rs +0 -0
  56. {unitforge-0.3.9 → unitforge-0.3.11}/src/small_linalg/matrix2.rs +0 -0
  57. {unitforge-0.3.9 → unitforge-0.3.11}/src/small_linalg/matrix2x3.rs +0 -0
  58. {unitforge-0.3.9 → unitforge-0.3.11}/src/small_linalg/matrix3.rs +0 -0
  59. {unitforge-0.3.9 → unitforge-0.3.11}/src/small_linalg/matrix3x2.rs +0 -0
  60. {unitforge-0.3.9 → unitforge-0.3.11}/src/small_linalg/vector2.rs +0 -0
  61. {unitforge-0.3.9 → unitforge-0.3.11}/src/small_linalg/vector3.rs +0 -0
  62. {unitforge-0.3.9 → unitforge-0.3.11}/src/tests/quantity_unit_tests.rs +0 -0
  63. {unitforge-0.3.9 → unitforge-0.3.11}/src/tests/serde_tests.rs +0 -0
  64. {unitforge-0.3.9 → unitforge-0.3.11}/tests/matrix2.rs +0 -0
  65. {unitforge-0.3.9 → unitforge-0.3.11}/tests/matrix2x3.rs +0 -0
  66. {unitforge-0.3.9 → unitforge-0.3.11}/tests/matrix3.rs +0 -0
  67. {unitforge-0.3.9 → unitforge-0.3.11}/tests/matrix3x2.rs +0 -0
  68. {unitforge-0.3.9 → unitforge-0.3.11}/tests/ndarray_dot.rs +0 -0
  69. {unitforge-0.3.9 → unitforge-0.3.11}/tests/quantities.rs +0 -0
  70. {unitforge-0.3.9 → unitforge-0.3.11}/tests/small_linalg_interfaces.rs +0 -0
  71. {unitforge-0.3.9 → unitforge-0.3.11}/tests/test_quantities.py +0 -0
  72. {unitforge-0.3.9 → unitforge-0.3.11}/tests/vector2.rs +0 -0
  73. {unitforge-0.3.9 → unitforge-0.3.11}/tests/vector3.rs +0 -0
@@ -1,5 +1,6 @@
1
1
  debug/
2
2
  target/
3
+ dist/
3
4
  **/*.rs.bk
4
5
  .idea/
5
6
  .gitlab-ci-local/
@@ -0,0 +1,87 @@
1
+ image: "rust:latest"
2
+
3
+ lint:
4
+ script:
5
+ - rustup component add clippy
6
+ - cargo clippy --no-deps -- -D warnings
7
+ allow_failure: true
8
+
9
+ lint_serde:
10
+ script:
11
+ - rustup component add clippy
12
+ - cargo clippy -F serde --no-deps -- -D warnings
13
+ allow_failure: true
14
+
15
+ lint_strum:
16
+ script:
17
+ - rustup component add clippy
18
+ - cargo clippy -F strum --no-deps -- -D warnings
19
+ allow_failure: true
20
+
21
+ lint_pyo3:
22
+ script:
23
+ - rustup component add clippy
24
+ - cargo clippy -F pyo3 --no-deps -- -D warnings
25
+ allow_failure: true
26
+
27
+ test:
28
+ script:
29
+ - rustc --version && cargo --version
30
+ - cargo test --workspace --verbose
31
+
32
+ test_serde:
33
+ script:
34
+ - rustc --version && cargo --version
35
+ - cargo test -F serde --workspace
36
+
37
+ test_strum:
38
+ script:
39
+ - cargo test -F strum --workspace
40
+
41
+ test_pyo3:
42
+ script:
43
+ - apt-get update && apt-get install -y python3 python3-venv python3.13-dev
44
+ - python3 -m venv .venv
45
+ - source .venv/bin/activate
46
+ - pip install --upgrade pip setuptools wheel maturin pytest numpy
47
+ - python scripts/generate_python_stubs.py
48
+ - maturin develop --features pyo3
49
+ - pytest tests
50
+ - deactivate
51
+
52
+ typecheck_pyo3_stubs:
53
+ script:
54
+ - apt-get update && apt-get install -y python3 python3-venv python3.13-dev
55
+ - python3 -m venv .venv
56
+ - source .venv/bin/activate
57
+ - pip install --upgrade pip setuptools wheel maturin numpy "mypy==1.8.0"
58
+ - python scripts/generate_python_stubs.py
59
+ - python scripts/generate_python_stubs.py --check
60
+ - MYPYPATH=python mypy --python-version 3.7 tests/typecheck/mypy_smoke.py
61
+ - maturin build --features pyo3 --out dist
62
+ - |
63
+ python - <<'PY'
64
+ import glob
65
+ import zipfile
66
+
67
+ wheels = sorted(glob.glob("dist/*.whl"))
68
+ if not wheels:
69
+ raise SystemExit("No wheels built in dist/")
70
+
71
+ required = {
72
+ "unitforge/__init__.py",
73
+ "unitforge/__init__.pyi",
74
+ "unitforge/py.typed",
75
+ }
76
+ with zipfile.ZipFile(wheels[-1]) as wheel:
77
+ names = set(wheel.namelist())
78
+ missing = sorted(required - names)
79
+ if missing:
80
+ raise SystemExit(f"Wheel is missing required typing artifacts: {missing}")
81
+ print("Wheel typing artifacts verified.")
82
+ PY
83
+ - pip install --force-reinstall --no-deps dist/*.whl
84
+ - TMP_DIR="$(mktemp -d)"
85
+ - cp tests/typecheck/mypy_smoke.py "$TMP_DIR"/
86
+ - (cd "$TMP_DIR" && mypy --python-version 3.7 mypy_smoke.py)
87
+ - deactivate
@@ -369,15 +369,22 @@ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
369
369
 
370
370
  [[package]]
371
371
  name = "unitforge"
372
- version = "0.3.9"
372
+ version = "0.3.11"
373
373
  dependencies = [
374
374
  "ndarray",
375
375
  "num-traits",
376
376
  "numpy",
377
377
  "pyo3",
378
- "regex",
379
378
  "serde",
380
379
  "serde_json",
381
380
  "strum",
382
381
  "strum_macros",
382
+ "unitforge_macros",
383
+ ]
384
+
385
+ [[package]]
386
+ name = "unitforge_macros"
387
+ version = "0.3.11"
388
+ dependencies = [
389
+ "regex",
383
390
  ]
@@ -4,7 +4,7 @@ crate-type = ["cdylib", "rlib"]
4
4
 
5
5
  [package]
6
6
  name = "unitforge"
7
- version = "0.3.9"
7
+ version = "0.3.11"
8
8
  edition = "2021"
9
9
  authors = ["Dr.Q team <info@askdrq.com>", "Henrik Stromberg <henrik@askdrq.com>"]
10
10
  description = "A library for unit and quantity consistent computations in Rust"
@@ -23,11 +23,12 @@ pyo3 = { version = "0.26.0", features = ["extension-module", "auto-initialize",
23
23
  numpy = { version = "0.26.0", optional = true }
24
24
  strum = { version = "0.27.2", optional = true }
25
25
  strum_macros = { version = "0.27.2", optional = true }
26
-
27
- [build-dependencies]
28
- regex = "1.11.1"
26
+ unitforge_macros = { version = "0.3.10", path = "unitforge_macros" }
29
27
 
30
28
  [features]
31
29
  serde = ["dep:serde", "dep:serde_json", "ndarray/serde"]
32
30
  pyo3 = ["dep:pyo3", "dep:numpy"]
33
31
  strum = ["dep:strum", "dep:strum_macros"]
32
+
33
+ [workspace]
34
+ members = ["unitforge_macros"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitforge
3
- Version: 0.3.9
3
+ Version: 0.3.11
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -9,7 +9,9 @@ Classifier: Topic :: Scientific/Engineering :: Physics
9
9
  Requires-Dist: numpy>=2.2.0
10
10
  Summary: A library for unit and quantity consistent computations in Rust
11
11
  Keywords: units,quantities,simulation,physics
12
+ Author: Dr.Q team <info@askdrq.com>, Henrik Stromberg <henrik@askdrq.com>
12
13
  Author-email: "Dr.Q team" <info@askdrq.com>, Henrik Stromberg <henrik@askdrq.com>
14
+ License: MIT
13
15
  Requires-Python: >=3.7
14
16
  Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
15
17
  Project-URL: Repository, https://gitlab.com/henrikjstromberg/unitforge
@@ -32,3 +32,5 @@ Repository = "https://gitlab.com/henrikjstromberg/unitforge"
32
32
  [tool.maturin]
33
33
  bindings = "pyo3"
34
34
  features = ["pyo3"]
35
+ python-source = "python"
36
+ module-name = "unitforge.unitforge"
@@ -0,0 +1,5 @@
1
+ from .unitforge import *
2
+
3
+ __doc__ = unitforge.__doc__
4
+ if hasattr(unitforge, "__all__"):
5
+ __all__ = unitforge.__all__
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env python3
2
+ """Generate mypy-friendly Python stubs for the unitforge package."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import difflib
8
+ import re
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Sequence, Set, Tuple
13
+
14
+ IMPL_QUANTITY_RE = re.compile(
15
+ r"impl_quantity!\(\s*(?P<quantity>\w+)\s*,\s*(?P<unit>\w+)\s*,",
16
+ re.MULTILINE,
17
+ )
18
+ IMPL_CONST_RE = re.compile(r"impl_const!\(\s*(?P<quantity>\w+)\s*,\s*(?P<name>\w+)\s*,")
19
+ MAKE_ALIAS_RE = re.compile(
20
+ r"make_alias!\(\s*(?P<base_quantity>\w+)\s*,\s*(?P<base_unit>\w+)\s*,\s*"
21
+ r"(?P<alias_quantity>\w+)\s*,\s*(?P<alias_unit>\w+)\s*\)"
22
+ )
23
+ IMPL_MUL_REL_SELF_RE = re.compile(
24
+ r"impl_mul_relation_with_self!\(\s*(?P<lhs>\w+)\s*,\s*(?P<res>\w+)\s*\)"
25
+ )
26
+ IMPL_MUL_REL_OTHER_RE = re.compile(
27
+ r"impl_mul_relation_with_other!\(\s*(?P<lhs>\w+)\s*,\s*(?P<rhs>\w+)\s*,\s*(?P<res>\w+)\s*\)"
28
+ )
29
+ IMPL_SQRT_RE = re.compile(r"impl_sqrt!\(\s*(?P<lhs>\w+)\s*,\s*(?P<res>\w+)\s*\)")
30
+ IMPL_MUL_RE = re.compile(
31
+ r"impl_mul!\(\s*(?P<lhs>\w+)\s*,\s*(?P<rhs>\w+)\s*,\s*(?P<res>\w+)\s*\)"
32
+ )
33
+ IMPL_DIV_RE = re.compile(
34
+ r"impl_div!\(\s*(?P<lhs>\w+)\s*,\s*(?P<rhs>\w+)\s*,\s*(?P<res>\w+)\s*\)"
35
+ )
36
+ IMPL_MUL_WITH_SELF_RE = re.compile(
37
+ r"impl_mul_with_self!\(\s*(?P<lhs>\w+)\s*,\s*(?P<res>\w+)\s*\)"
38
+ )
39
+ IMPL_DIV_SELF_TO_F64_RE = re.compile(r"impl_div_with_self_to_f64!\(\s*(?P<lhs>\w+)\s*\)")
40
+ RTRUE_DIV_RE = re.compile(
41
+ r"fn\s+__rtruediv__\s*\(\s*rhs:\s*PyRef<Self>\s*,\s*lhs:\s*f64\s*\)\s*->\s*PyResult<(?P<res>\w+)>"
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class QuantityInfo:
47
+ name: str
48
+ unit: str
49
+ unit_variants: List[str]
50
+ constants: List[str]
51
+ rtruediv_return: Optional[str]
52
+
53
+
54
+ @dataclass
55
+ class OperatorMaps:
56
+ mul: Dict[str, Dict[str, str]]
57
+ div: Dict[str, Dict[str, str]]
58
+
59
+
60
+ def _strip_line_comment(line: str) -> str:
61
+ return line.split("//", 1)[0].strip()
62
+
63
+
64
+ def _extract_braced_block(source: str, start_index: int) -> str:
65
+ brace_start = source.find("{", start_index)
66
+ if brace_start == -1:
67
+ raise ValueError("opening brace not found")
68
+
69
+ depth = 0
70
+ for index in range(brace_start, len(source)):
71
+ char = source[index]
72
+ if char == "{":
73
+ depth += 1
74
+ elif char == "}":
75
+ depth -= 1
76
+ if depth == 0:
77
+ return source[brace_start + 1 : index]
78
+ raise ValueError("closing brace not found")
79
+
80
+
81
+ def parse_enum_variants(source: str, enum_name: str) -> List[str]:
82
+ enum_match = re.search(rf"pub enum\s+{re.escape(enum_name)}\s*\{{", source)
83
+ if not enum_match:
84
+ raise ValueError("enum not found")
85
+
86
+ body = _extract_braced_block(source, enum_match.start())
87
+ variants: List[str] = []
88
+
89
+ for raw_line in body.splitlines():
90
+ line = _strip_line_comment(raw_line)
91
+ if not line or line.startswith("#"):
92
+ continue
93
+ for segment in line.split(","):
94
+ token = segment.strip()
95
+ if not token:
96
+ continue
97
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token):
98
+ variants.append(token)
99
+ return variants
100
+
101
+
102
+ def parse_quantity_file(path: Path) -> Optional[QuantityInfo]:
103
+ source = path.read_text(encoding="utf-8")
104
+ quantity_match = IMPL_QUANTITY_RE.search(source)
105
+ if quantity_match is None:
106
+ return None
107
+
108
+ quantity_name = quantity_match.group("quantity")
109
+ unit_name = quantity_match.group("unit")
110
+ unit_variants = parse_enum_variants(source, unit_name)
111
+
112
+ constants: List[str] = []
113
+ for const_match in IMPL_CONST_RE.finditer(source):
114
+ if const_match.group("quantity") == quantity_name:
115
+ constants.append(const_match.group("name"))
116
+
117
+ rtruediv_match = RTRUE_DIV_RE.search(source)
118
+ rtruediv_return = rtruediv_match.group("res") if rtruediv_match else None
119
+
120
+ return QuantityInfo(
121
+ name=quantity_name,
122
+ unit=unit_name,
123
+ unit_variants=unit_variants,
124
+ constants=constants,
125
+ rtruediv_return=rtruediv_return,
126
+ )
127
+
128
+
129
+ def parse_quantities(quantities_dir: Path) -> List[QuantityInfo]:
130
+ quantities: List[QuantityInfo] = []
131
+ for path in sorted(quantities_dir.glob("*.rs")):
132
+ parsed = parse_quantity_file(path)
133
+ if parsed is not None:
134
+ quantities.append(parsed)
135
+ quantities.sort(key=lambda item: item.name)
136
+ return quantities
137
+
138
+
139
+ def parse_aliases(quantities_dir: Path) -> List[Tuple[str, str, str, str]]:
140
+ aliases: List[Tuple[str, str, str, str]] = []
141
+ seen = set()
142
+
143
+ for path in sorted(quantities_dir.glob("*.rs")):
144
+ source = path.read_text(encoding="utf-8")
145
+ for match in MAKE_ALIAS_RE.finditer(source):
146
+ alias = (
147
+ match.group("base_quantity"),
148
+ match.group("base_unit"),
149
+ match.group("alias_quantity"),
150
+ match.group("alias_unit"),
151
+ )
152
+ if alias not in seen:
153
+ seen.add(alias)
154
+ aliases.append(alias)
155
+
156
+ aliases.sort(key=lambda alias: (alias[2], alias[3]))
157
+ return aliases
158
+
159
+
160
+ def parse_sqrt_map(src_root: Path) -> Dict[str, str]:
161
+ sqrt_map: Dict[str, str] = {}
162
+ for path in sorted(src_root.rglob("*.rs")):
163
+ source = path.read_text(encoding="utf-8")
164
+ for match in IMPL_MUL_REL_SELF_RE.finditer(source):
165
+ lhs = match.group("lhs")
166
+ res = match.group("res")
167
+ sqrt_map[res] = lhs
168
+ for match in IMPL_SQRT_RE.finditer(source):
169
+ lhs = match.group("lhs")
170
+ res = match.group("res")
171
+ sqrt_map[lhs] = res
172
+ return sqrt_map
173
+
174
+
175
+ def _add_operator(
176
+ table: Dict[str, Dict[str, str]],
177
+ lhs: str,
178
+ rhs: str,
179
+ result: str,
180
+ ) -> None:
181
+ if lhs not in table:
182
+ table[lhs] = {}
183
+ prev = table[lhs].get(rhs)
184
+ if prev is not None and prev != result:
185
+ raise ValueError(
186
+ f"Conflicting operator mapping for {lhs} and {rhs}: {prev} vs {result}"
187
+ )
188
+ table[lhs][rhs] = result
189
+
190
+
191
+ def parse_operator_maps(src_root: Path) -> OperatorMaps:
192
+ mul: Dict[str, Dict[str, str]] = {}
193
+ div: Dict[str, Dict[str, str]] = {}
194
+ files = [src_root / "relations.rs"] + sorted((src_root / "quantities").glob("*.rs"))
195
+
196
+ for path in files:
197
+ source = path.read_text(encoding="utf-8")
198
+
199
+ for match in IMPL_MUL_REL_OTHER_RE.finditer(source):
200
+ lhs = match.group("lhs")
201
+ rhs = match.group("rhs")
202
+ res = match.group("res")
203
+ _add_operator(mul, lhs, rhs, res)
204
+ _add_operator(mul, rhs, lhs, res)
205
+ _add_operator(div, res, lhs, rhs)
206
+ _add_operator(div, res, rhs, lhs)
207
+
208
+ for match in IMPL_MUL_REL_SELF_RE.finditer(source):
209
+ lhs = match.group("lhs")
210
+ res = match.group("res")
211
+ _add_operator(mul, lhs, lhs, res)
212
+ _add_operator(div, res, lhs, lhs)
213
+
214
+ for match in IMPL_MUL_RE.finditer(source):
215
+ lhs = match.group("lhs")
216
+ rhs = match.group("rhs")
217
+ res = match.group("res")
218
+ _add_operator(mul, lhs, rhs, res)
219
+ _add_operator(mul, rhs, lhs, res)
220
+
221
+ for match in IMPL_DIV_RE.finditer(source):
222
+ lhs = match.group("lhs")
223
+ rhs = match.group("rhs")
224
+ res = match.group("res")
225
+ _add_operator(div, lhs, rhs, res)
226
+
227
+ for match in IMPL_MUL_WITH_SELF_RE.finditer(source):
228
+ lhs = match.group("lhs")
229
+ res = match.group("res")
230
+ _add_operator(mul, lhs, lhs, res)
231
+
232
+ for match in IMPL_DIV_SELF_TO_F64_RE.finditer(source):
233
+ lhs = match.group("lhs")
234
+ _add_operator(div, lhs, lhs, "f64")
235
+
236
+ return OperatorMaps(mul=mul, div=div)
237
+
238
+
239
+ def _format_result_annotation(result: str, quantity_names: Set[str]) -> Optional[str]:
240
+ if result == "f64":
241
+ return "float"
242
+ if result in quantity_names:
243
+ return f'"{result}"'
244
+ return None
245
+
246
+
247
+ def render_vector3_stub() -> List[str]:
248
+ return [
249
+ "class Vector3:",
250
+ " def __init__(self, x: Any, y: Any, z: Any, unit: Optional[Any] = ...) -> None: ...",
251
+ " @staticmethod",
252
+ ' def zero() -> "Vector3": ...',
253
+ " @staticmethod",
254
+ ' def x() -> "Vector3": ...',
255
+ " @staticmethod",
256
+ ' def y() -> "Vector3": ...',
257
+ " @staticmethod",
258
+ ' def z() -> "Vector3": ...',
259
+ " def __getitem__(self, index: int) -> Any: ...",
260
+ " def __setitem__(self, index: int, value: Any) -> None: ...",
261
+ ' def __neg__(self) -> "Vector3": ...',
262
+ ' def __add__(self, other: "Vector3") -> "Vector3": ...',
263
+ ' def __sub__(self, other: "Vector3") -> "Vector3": ...',
264
+ " def __repr__(self) -> str: ...",
265
+ " def __str__(self) -> str: ...",
266
+ " def to_list(self) -> List[Any]: ...",
267
+ " @staticmethod",
268
+ ' def from_list(lst: Sequence[Any], unit: Optional[Any] = ...) -> "Vector3": ...',
269
+ " @staticmethod",
270
+ ' def from_array(array: Any, unit: Optional[Any] = ...) -> "Vector3": ...',
271
+ " def to_array(self, unit: Optional[Any] = ...) -> Any: ...",
272
+ " def norm(self) -> Any: ...",
273
+ ' def to_unit_vector(self) -> "Vector3": ...',
274
+ ' def dot_vec(self, other: "Vector3") -> Any: ...',
275
+ ' def cross(self, other: "Vector3") -> "Vector3": ...',
276
+ " def __mul__(self, rhs: Any) -> Vector3: ...",
277
+ " def __truediv__(self, rhs: Any) -> Vector3: ...",
278
+ ]
279
+
280
+
281
+ def render_matrix3_stub() -> List[str]:
282
+ return [
283
+ "class Matrix3:",
284
+ " def __init__(self, data: Optional[Any] = ..., unit: Optional[Any] = ...) -> None: ...",
285
+ " @staticmethod",
286
+ ' def zero() -> "Matrix3": ...',
287
+ " @staticmethod",
288
+ ' def identity() -> "Matrix3": ...',
289
+ " def __getitem__(self, indices: Tuple[int, int]) -> Any: ...",
290
+ " def __setitem__(self, indices: Tuple[int, int], value: Any) -> None: ...",
291
+ ' def __neg__(self) -> "Matrix3": ...',
292
+ " def __repr__(self) -> str: ...",
293
+ " def __str__(self) -> str: ...",
294
+ " @staticmethod",
295
+ ' def from_list(lst: Sequence[Sequence[Any]], unit: Optional[Any] = ...) -> "Matrix3": ...',
296
+ " def to_list(self, unit: Optional[Any] = ...) -> List[List[Any]]: ...",
297
+ " @staticmethod",
298
+ ' def from_array(array: Any, unit: Optional[Any] = ...) -> "Matrix3": ...',
299
+ " def to_array(self, unit: Optional[Any] = ...) -> Any: ...",
300
+ " @staticmethod",
301
+ ' def from_rows(rows: Sequence[Vector3]) -> "Matrix3": ...',
302
+ " @staticmethod",
303
+ ' def from_columns(columns: Sequence[Vector3]) -> "Matrix3": ...',
304
+ ' def transpose(self) -> "Matrix3": ...',
305
+ " def get_column(self, index: int) -> Vector3: ...",
306
+ " def set_column(self, index: int, value: Vector3) -> None: ...",
307
+ " def get_row(self, index: int) -> Vector3: ...",
308
+ " def set_row(self, index: int, value: Vector3) -> None: ...",
309
+ ]
310
+
311
+
312
+ def render_unit_stub(quantity: QuantityInfo) -> List[str]:
313
+ lines = [f"class {quantity.unit}:"]
314
+ if quantity.unit_variants:
315
+ for variant in quantity.unit_variants:
316
+ lines.append(f' {variant}: ClassVar["{quantity.unit}"]')
317
+ else:
318
+ lines.append(" pass")
319
+ return lines
320
+
321
+
322
+ def render_quantity_stub(
323
+ quantity: QuantityInfo,
324
+ sqrt_map: Dict[str, str],
325
+ operator_maps: OperatorMaps,
326
+ quantity_names: Set[str],
327
+ ) -> List[str]:
328
+ q = quantity.name
329
+ u = quantity.unit
330
+ lines = [
331
+ f"class {q}:",
332
+ f' def __init__(self, value: float, unit: "{u}") -> None: ...',
333
+ " @classmethod",
334
+ f' def zero(cls: Type["{q}"]) -> "{q}": ...',
335
+ f' def to(self, unit: "{u}") -> float: ...',
336
+ f' def close_abs(self, other: "{q}", threshold: "{q}") -> bool: ...',
337
+ f' def close_rel(self, other: "{q}", threshold: float) -> bool: ...',
338
+ f' def __neg__(self) -> "{q}": ...',
339
+ f' def __abs__(self) -> "{q}": ...',
340
+ f' def __add__(self, rhs: "{q}") -> "{q}": ...',
341
+ f' def __sub__(self, rhs: "{q}") -> "{q}": ...',
342
+ " @overload",
343
+ f' def __mul__(self, rhs: float) -> "{q}": ...',
344
+ ]
345
+
346
+ mul_ops = operator_maps.mul.get(q, {})
347
+ for rhs in sorted(mul_ops):
348
+ if rhs not in quantity_names:
349
+ continue
350
+ res = _format_result_annotation(mul_ops[rhs], quantity_names)
351
+ if res is None:
352
+ continue
353
+ lines.append(" @overload")
354
+ lines.append(f' def __mul__(self, rhs: "{rhs}") -> {res}: ...')
355
+ lines.append(" @overload")
356
+ lines.append(" def __mul__(self, rhs: Any) -> Any: ...")
357
+
358
+ lines.append(" @overload")
359
+ lines.append(f' def __truediv__(self, rhs: float) -> "{q}": ...')
360
+ div_ops = operator_maps.div.get(q, {})
361
+ for rhs in sorted(div_ops):
362
+ if rhs not in quantity_names:
363
+ continue
364
+ res = _format_result_annotation(div_ops[rhs], quantity_names)
365
+ if res is None:
366
+ continue
367
+ lines.append(" @overload")
368
+ lines.append(f' def __truediv__(self, rhs: "{rhs}") -> {res}: ...')
369
+ lines.append(" @overload")
370
+ lines.append(" def __truediv__(self, rhs: Any) -> Any: ...")
371
+ lines.append(f' def __rmul__(self, rhs: float) -> "{q}": ...')
372
+ lines.append(" def __repr__(self) -> str: ...")
373
+ lines.append(" def __str__(self) -> str: ...")
374
+
375
+ if quantity.rtruediv_return:
376
+ lines.append(f' def __rtruediv__(self, lhs: float) -> "{quantity.rtruediv_return}": ...')
377
+
378
+ for const_name in quantity.constants:
379
+ lines.extend(
380
+ [
381
+ " @staticmethod",
382
+ f' def {const_name}() -> "{q}": ...',
383
+ ]
384
+ )
385
+
386
+ sqrt_return = sqrt_map.get(q)
387
+ if sqrt_return:
388
+ lines.append(f' def sqrt(self) -> "{sqrt_return}": ...')
389
+
390
+ return lines
391
+
392
+
393
+ def render_stub(
394
+ quantities: Sequence[QuantityInfo],
395
+ aliases: Sequence[Tuple[str, str, str, str]],
396
+ sqrt_map: Dict[str, str],
397
+ operator_maps: OperatorMaps,
398
+ ) -> str:
399
+ quantity_names = {quantity.name for quantity in quantities}
400
+
401
+ lines: List[str] = [
402
+ "# This file is generated by scripts/generate_python_stubs.py.",
403
+ "# Do not edit manually.",
404
+ "",
405
+ "from typing import Any, ClassVar, List, Optional, Sequence, Tuple, Type, overload",
406
+ "",
407
+ ]
408
+
409
+ lines.extend(render_vector3_stub())
410
+ lines.append("")
411
+ lines.extend(render_matrix3_stub())
412
+ lines.append("")
413
+
414
+ for quantity in quantities:
415
+ lines.extend(render_unit_stub(quantity))
416
+ lines.append("")
417
+ lines.extend(
418
+ render_quantity_stub(
419
+ quantity=quantity,
420
+ sqrt_map=sqrt_map,
421
+ operator_maps=operator_maps,
422
+ quantity_names=quantity_names,
423
+ )
424
+ )
425
+ lines.append("")
426
+
427
+ for base_quantity, base_unit, alias_quantity, alias_unit in aliases:
428
+ lines.append(f"{alias_quantity} = {base_quantity}")
429
+ lines.append(f"{alias_unit} = {base_unit}")
430
+
431
+ if aliases:
432
+ lines.append("")
433
+
434
+ return "\n".join(lines)
435
+
436
+
437
+ def _print_diff(path: Path, expected: str, current: str) -> None:
438
+ diff = difflib.unified_diff(
439
+ current.splitlines(),
440
+ expected.splitlines(),
441
+ fromfile=f"{path} (current)",
442
+ tofile=f"{path} (expected)",
443
+ lineterm="",
444
+ )
445
+ for line in diff:
446
+ print(line)
447
+
448
+
449
+ def sync_file(path: Path, content: str, check: bool) -> bool:
450
+ if path.exists():
451
+ current = path.read_text(encoding="utf-8")
452
+ else:
453
+ current = None
454
+
455
+ if current == content:
456
+ return False
457
+
458
+ if check:
459
+ print(f"File is out of date: {path}")
460
+ if current is not None:
461
+ _print_diff(path, content, current)
462
+ return True
463
+
464
+ path.parent.mkdir(parents=True, exist_ok=True)
465
+ path.write_text(content, encoding="utf-8")
466
+ return False
467
+
468
+
469
+ def parse_args() -> argparse.Namespace:
470
+ parser = argparse.ArgumentParser(description=__doc__)
471
+ parser.add_argument(
472
+ "--check",
473
+ action="store_true",
474
+ help="Fail if generated files are missing or outdated.",
475
+ )
476
+ return parser.parse_args()
477
+
478
+
479
+ def main() -> int:
480
+ args = parse_args()
481
+ repo_root = Path(__file__).resolve().parents[1]
482
+ quantities_dir = repo_root / "src" / "quantities"
483
+ src_root = repo_root / "src"
484
+ out_pkg = repo_root / "python" / "unitforge"
485
+
486
+ quantities = parse_quantities(quantities_dir)
487
+ aliases = parse_aliases(quantities_dir)
488
+ sqrt_map = parse_sqrt_map(src_root)
489
+ operator_maps = parse_operator_maps(src_root)
490
+ stub_content = render_stub(quantities, aliases, sqrt_map, operator_maps)
491
+
492
+ out_of_date = False
493
+ out_of_date |= sync_file(out_pkg / "__init__.pyi", stub_content, args.check)
494
+ out_of_date |= sync_file(out_pkg / "py.typed", "", args.check)
495
+
496
+ if out_of_date and args.check:
497
+ return 1
498
+ return 0
499
+
500
+
501
+ if __name__ == "__main__":
502
+ sys.exit(main())
@@ -15,7 +15,7 @@ macro_rules! impl_quantity {
15
15
 
16
16
  #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
17
17
  #[cfg(feature = "pyo3")]
18
- #[pyclass]
18
+ #[pyclass(module = "unitforge")]
19
19
  #[derive(Copy, Clone)]
20
20
  pub struct $type {
21
21
  pub(crate) multiplier: f64,