gitcad-core 0.7.7__py3-none-any.whl

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.
gitcad/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.7.6"
gitcad/canonical.py ADDED
@@ -0,0 +1,56 @@
1
+ """The one canonicalization policy for every serialized number (ADR-0004).
2
+
3
+ Canonical text is load-bearing: identities, content hashes, lockfiles, and
4
+ semantic diffs all assume "semantically equal ⇒ byte-identical". Floats are
5
+ where that promise quietly breaks, so every module that serializes model data
6
+ routes numbers through here. The rules:
7
+
8
+ 1. **Non-finite numbers are rejected.** NaN/Infinity are not geometry; JSON's
9
+ default ``allow_nan=True`` would emit non-JSON and break hash equality
10
+ (``nan != nan``).
11
+ 2. **Negative zero is normalized to zero.** ``round(-1e-9, 6)`` is ``-0.0``,
12
+ which serializes as ``"-0.0"`` — float noise crossing zero must never split
13
+ an identity or a hash.
14
+ 3. **All non-bool numbers serialize as floats.** ``{"dx": 1}`` and
15
+ ``{"dx": 1.0}`` are the same model and must hash identically.
16
+
17
+ (Reviewed 2026-07-22: all three holes were verified live before this module
18
+ existed — see docs/reviews/2026-07-22-early-architecture-review.md.)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import math
25
+ from typing import Any
26
+
27
+ from gitcad.errors import GitcadError
28
+
29
+
30
+ def canon_number(v: float) -> float:
31
+ """Apply rules 1-3 to a single number."""
32
+ if not math.isfinite(v):
33
+ raise GitcadError(f"non-finite number is not serializable: {v!r}")
34
+ return float(v) + 0.0 # int -> float; -0.0 + 0.0 == 0.0
35
+
36
+
37
+ def canonicalize(obj: Any) -> Any:
38
+ """Recursively canonicalize every number in a JSON-able structure.
39
+ Bools are preserved (bool is an int subclass — check it first)."""
40
+ if isinstance(obj, bool) or obj is None or isinstance(obj, str):
41
+ return obj
42
+ if isinstance(obj, (int, float)):
43
+ return canon_number(obj)
44
+ if isinstance(obj, dict):
45
+ return {k: canonicalize(v) for k, v in obj.items()}
46
+ if isinstance(obj, (list, tuple)):
47
+ return [canonicalize(v) for v in obj]
48
+ raise GitcadError(f"not canonically serializable: {type(obj).__name__}")
49
+
50
+
51
+ def canonical_json(obj: Any, *, indent: int | None = None) -> str:
52
+ """Canonical JSON text: numbers per the rules above, sorted keys, no NaN
53
+ escape hatch. THE serializer for model/board/part/lock text."""
54
+ return json.dumps(canonicalize(obj), indent=indent, sort_keys=True,
55
+ allow_nan=False,
56
+ separators=(",", ":") if indent is None else None)
gitcad/errors.py ADDED
@@ -0,0 +1,84 @@
1
+ """Structured errors.
2
+
3
+ Errors here are not just for humans — a kernel/validation failure carries enough
4
+ structure to become a *bug-repro payload* (see :mod:`gitcad.report`). Every
5
+ failure exposes a stable ``fingerprint_key`` so 10,000 users hitting one kernel
6
+ bug produce one deduplicated issue, not 10,000.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+
15
+ class GitcadError(Exception):
16
+ """Base class for all gitcad errors."""
17
+
18
+
19
+ @dataclass
20
+ class FailureSignature:
21
+ """The deduplication key for a failure.
22
+
23
+ Deliberately excludes user coordinates and model contents — it is derived
24
+ only from *what went wrong* (the operation and the kernel's own diagnostic),
25
+ so it is safe to transmit and stable across unrelated models.
26
+ """
27
+
28
+ op: str
29
+ """The intent-level operation that failed, e.g. ``"fillet"``."""
30
+
31
+ diagnostic: str
32
+ """The kernel/validator's own message class, e.g. ``"BRepCheck:BadOrientation"``."""
33
+
34
+ kernel: str = "unknown"
35
+ """Backend + version, e.g. ``"occt-7.8.1"``."""
36
+
37
+ def key(self) -> str:
38
+ return f"{self.kernel}|{self.op}|{self.diagnostic}"
39
+
40
+
41
+ class KernelError(GitcadError):
42
+ """A geometry-kernel operation failed.
43
+
44
+ Carries a :class:`FailureSignature` so the failure can be fingerprinted and
45
+ the model auto-reduced to a minimal, synthetic repro before anything leaves
46
+ the user's machine.
47
+ """
48
+
49
+ def __init__(self, message: str, signature: FailureSignature) -> None:
50
+ super().__init__(message)
51
+ self.signature = signature
52
+
53
+
54
+ class GeometryInvalidError(KernelError):
55
+ """A produced shape violated a geometric invariant (e.g. not watertight)."""
56
+
57
+
58
+ class IdentityError(GitcadError):
59
+ """A stable-identity operation failed (e.g. dangling entity reference)."""
60
+
61
+ def __init__(self, message: str, *, entity: str | None = None) -> None:
62
+ super().__init__(message)
63
+ self.entity = entity
64
+
65
+
66
+ @dataclass
67
+ class ValidationReport:
68
+ """Result of validating a shape or document. Machine-readable by design so
69
+ an agent can act on it instead of parsing prose."""
70
+
71
+ ok: bool
72
+ checks: dict[str, Any] = field(default_factory=dict)
73
+ violations: list[str] = field(default_factory=list)
74
+
75
+ def raise_if_invalid(self, op: str, kernel: str = "unknown") -> None:
76
+ if not self.ok:
77
+ # Fingerprints must carry only the closed-vocabulary violation
78
+ # CODES, never the per-instance detail after the first colon —
79
+ # detail embeds designators/dimensions (unsafe to transmit, per
80
+ # the FailureSignature contract) and varies per model (destroying
81
+ # dedup). Reviewed 2026-07-22. Violation format: "code:detail".
82
+ codes = sorted({v.split(":", 1)[0] for v in self.violations})
83
+ sig = FailureSignature(op=op, diagnostic=";".join(codes), kernel=kernel)
84
+ raise GeometryInvalidError(f"{op} produced invalid geometry: {self.violations}", sig)
gitcad/expr.py ADDED
@@ -0,0 +1,148 @@
1
+ """Safe arithmetic expressions — the engine behind named parameters.
2
+
3
+ A tiny, auditable evaluator over Python's ast: numbers, + - * / // % **,
4
+ unary +/-, parentheses, parameter names, and a whitelist of math
5
+ functions. No attribute access, no subscripts, no comprehensions, no
6
+ eval — an expression can compute a dimension and nothing else.
7
+
8
+ Convention (spreadsheet-style, used across gitcad): a string value
9
+ beginning with ``=`` is an expression; anything else is a literal.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import math
16
+ from typing import Any, Mapping
17
+
18
+ from gitcad.errors import GitcadError
19
+
20
+
21
+ class ExprError(GitcadError):
22
+ pass
23
+
24
+
25
+ class UndefinedNameError(ExprError):
26
+ def __init__(self, name: str) -> None:
27
+ super().__init__(f"undefined name {name!r} in expression")
28
+ self.name = name
29
+
30
+
31
+ _FUNCS: dict[str, Any] = {
32
+ "sin": lambda x: math.sin(math.radians(x)),
33
+ "cos": lambda x: math.cos(math.radians(x)),
34
+ "tan": lambda x: math.tan(math.radians(x)),
35
+ "asin": lambda x: math.degrees(math.asin(x)),
36
+ "acos": lambda x: math.degrees(math.acos(x)),
37
+ "atan": lambda x: math.degrees(math.atan(x)),
38
+ "atan2": lambda y, x: math.degrees(math.atan2(y, x)),
39
+ "sqrt": math.sqrt,
40
+ "abs": abs,
41
+ "min": min,
42
+ "max": max,
43
+ "floor": math.floor,
44
+ "ceil": math.ceil,
45
+ "round": round,
46
+ }
47
+ _CONSTS = {"pi": math.pi}
48
+
49
+ _BINOPS = {
50
+ ast.Add: lambda a, b: a + b,
51
+ ast.Sub: lambda a, b: a - b,
52
+ ast.Mult: lambda a, b: a * b,
53
+ ast.Div: lambda a, b: a / b,
54
+ ast.FloorDiv: lambda a, b: a // b,
55
+ ast.Mod: lambda a, b: a % b,
56
+ ast.Pow: lambda a, b: a ** b,
57
+ }
58
+
59
+
60
+ def eval_expr(expr: str, env: Mapping[str, float]) -> float:
61
+ """Evaluate ``expr`` with parameter values from ``env``. Angles for
62
+ trig are DEGREES (the CAD convention). Raises UndefinedNameError for
63
+ unknown names, ExprError for anything outside the whitelist."""
64
+ try:
65
+ tree = ast.parse(expr, mode="eval")
66
+ except SyntaxError as e:
67
+ raise ExprError(f"bad expression {expr!r}: {e.msg}") from None
68
+
69
+ def ev(node: ast.AST) -> float:
70
+ if isinstance(node, ast.Expression):
71
+ return ev(node.body)
72
+ if isinstance(node, ast.Constant):
73
+ if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
74
+ return float(node.value)
75
+ raise ExprError(f"non-numeric constant {node.value!r} in {expr!r}")
76
+ if isinstance(node, ast.Name):
77
+ if node.id in env:
78
+ return float(env[node.id])
79
+ if node.id in _CONSTS:
80
+ return _CONSTS[node.id]
81
+ raise UndefinedNameError(node.id)
82
+ if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS:
83
+ return _BINOPS[type(node.op)](ev(node.left), ev(node.right))
84
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
85
+ v = ev(node.operand)
86
+ return -v if isinstance(node.op, ast.USub) else v
87
+ if isinstance(node, ast.Call):
88
+ if (isinstance(node.func, ast.Name) and node.func.id in _FUNCS
89
+ and not node.keywords):
90
+ return float(_FUNCS[node.func.id](*[ev(a) for a in node.args]))
91
+ raise ExprError(f"function not allowed in {expr!r}")
92
+ raise ExprError(f"construct {type(node).__name__} not allowed in {expr!r}")
93
+
94
+ return ev(tree)
95
+
96
+
97
+ def is_expression(value: Any) -> bool:
98
+ """The gitcad convention: strings starting with '=' are expressions."""
99
+ return isinstance(value, str) and value.startswith("=")
100
+
101
+
102
+ def resolve_value(value: Any, env: Mapping[str, float]) -> Any:
103
+ """Resolve one value: '=expr' strings evaluate; lists/dicts recurse;
104
+ everything else passes through untouched (enums, paths, ids)."""
105
+ if is_expression(value):
106
+ return eval_expr(value[1:], env)
107
+ if isinstance(value, list):
108
+ return [resolve_value(v, env) for v in value]
109
+ if isinstance(value, tuple):
110
+ return tuple(resolve_value(v, env) for v in value)
111
+ if isinstance(value, dict):
112
+ return {k: resolve_value(v, env) for k, v in value.items()}
113
+ return value
114
+
115
+
116
+ def resolve_table(parameters: Mapping[str, Any]) -> dict[str, float]:
117
+ """Resolve a parameter table where values may reference each other
118
+ ('=W*2'). Deterministic, cycle-detecting, fail-loud."""
119
+ env: dict[str, float] = {}
120
+ pending = dict(parameters)
121
+ progress = True
122
+ while pending and progress:
123
+ progress = False
124
+ for name in sorted(pending):
125
+ v = pending[name]
126
+ if not is_expression(v):
127
+ try:
128
+ env[name] = float(v)
129
+ except (TypeError, ValueError):
130
+ raise ExprError(
131
+ f"parameter {name!r}: value {v!r} is neither a number "
132
+ f"nor an '=' expression") from None
133
+ del pending[name]
134
+ progress = True
135
+ continue
136
+ try:
137
+ env[name] = eval_expr(v[1:], env)
138
+ except UndefinedNameError as e:
139
+ if e.name not in pending:
140
+ raise ExprError(
141
+ f"parameter {name!r} references undefined {e.name!r}") from None
142
+ continue # dependency not resolved yet
143
+ del pending[name]
144
+ progress = True
145
+ if pending:
146
+ raise ExprError(
147
+ f"parameter cycle: {sorted(pending)} reference each other")
148
+ return env
gitcad/identity.py ADDED
@@ -0,0 +1,134 @@
1
+ """Stable entity identity — the topological-naming fix.
2
+
3
+ The problem: a downstream feature references "face 7". An upstream edit adds a
4
+ feature, the kernel renumbers faces, and "face 7" silently becomes a different
5
+ face. Every reference downstream corrupts. This is FreeCAD's most notorious
6
+ weakness and it is *fatal* to a git workflow, where merges and rebases reorder
7
+ operations constantly (ADR-0003).
8
+
9
+ The fix: an entity's identity is derived from **how it was constructed** (its
10
+ lineage) plus a **geometric fingerprint** — never from an ordinal index. Two
11
+ rebuilds of the same construction yield the same ID; an unrelated edit upstream
12
+ does not perturb the IDs of entities it didn't touch.
13
+
14
+ This module is pure Python and has no kernel dependency, so identity logic is
15
+ fully unit-testable without OCCT.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ from gitcad.canonical import canonical_json
26
+ from gitcad.errors import GitcadError
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class EntityId:
31
+ """A durable reference to a topological entity (face/edge/vertex)."""
32
+
33
+ value: str
34
+
35
+ def __str__(self) -> str: # pragma: no cover - trivial
36
+ return self.value
37
+
38
+
39
+ def _round_geo(descriptor: dict[str, Any], ndigits: int) -> dict[str, Any]:
40
+ """Round geometric quantities so floating-point noise doesn't split an
41
+ identity. ``+ 0.0`` normalizes the ``-0.0`` that rounding a tiny negative
42
+ produces — without it, noise crossing zero splits identities (reviewed
43
+ 2026-07-22). Non-numeric fields pass through unchanged."""
44
+ def r(x: Any) -> Any:
45
+ return round(x, ndigits) + 0.0 if isinstance(x, float) else x
46
+
47
+ out: dict[str, Any] = {}
48
+ for k, v in descriptor.items():
49
+ if isinstance(v, (list, tuple)):
50
+ out[k] = [r(x) for x in v]
51
+ else:
52
+ out[k] = r(v)
53
+ return out
54
+
55
+
56
+ class IdentityService:
57
+ """Default identity backend: lineage + rounded geometric fingerprint hash.
58
+
59
+ ``assign`` is deterministic and collision-resistant across unrelated
60
+ constructions. ``resolve`` re-binds a stored ID after a rebuild by scoring
61
+ candidate entities against the fingerprint embedded in the ID.
62
+ """
63
+
64
+ def __init__(self, *, geo_tolerance_digits: int = 6, id_length: int = 16) -> None:
65
+ self._digits = geo_tolerance_digits
66
+ self._id_length = id_length
67
+ # Maps an assigned id -> the fingerprint payload it was minted from, so
68
+ # resolve() can re-match without re-deriving lineage.
69
+ self._registry: dict[str, dict[str, Any]] = {}
70
+
71
+ def assign(self, descriptor: dict[str, Any], lineage: tuple[str, ...]) -> str:
72
+ """Mint a stable id for an entity.
73
+
74
+ ``descriptor``: the kernel's order-independent semantic description of
75
+ the entity (surface type, rounded area, adjacency roles, ...).
76
+ ``lineage``: the ids of the features that created it (creation history).
77
+ """
78
+ payload = {
79
+ "lineage": list(lineage),
80
+ "geo": _round_geo(descriptor, self._digits),
81
+ }
82
+ digest = hashlib.blake2b(canonical_json(payload).encode(), digest_size=self._id_length).hexdigest()
83
+ entity_id = f"e_{digest}"
84
+ self._registry[entity_id] = payload
85
+ return entity_id
86
+
87
+ # -- persistence (ADR-0003: stored ids must resolve in future processes) --
88
+
89
+ SCHEMA = "gitcad/identity@1"
90
+
91
+ def dumps(self) -> str:
92
+ """Canonical text of the registry — commit it alongside the model
93
+ (like a lockfile) so entity ids stored in document text can re-bind
94
+ after a rebuild in another process."""
95
+ return canonical_json({"schema": self.SCHEMA, "registry": self._registry}, indent=2) + "\n"
96
+
97
+ @classmethod
98
+ def loads(cls, text: str, **kwargs: Any) -> "IdentityService":
99
+ doc = json.loads(text)
100
+ if doc.get("schema") != cls.SCHEMA:
101
+ raise GitcadError(f"unsupported identity schema {doc.get('schema')!r}")
102
+ svc = cls(**kwargs)
103
+ svc._registry = dict(doc["registry"])
104
+ return svc
105
+
106
+ def resolve(self, entity_id: str, candidates: list[dict[str, Any]]) -> dict[str, Any] | None:
107
+ """Re-bind ``entity_id`` to the best current candidate after a rebuild.
108
+
109
+ Returns the winning candidate descriptor, or ``None`` if nothing matches
110
+ well enough (the entity was genuinely removed). Scoring is a simple
111
+ geometric-similarity vote; a real backend would weight adjacency too.
112
+ """
113
+ payload = self._registry.get(entity_id)
114
+ if payload is None:
115
+ return None
116
+ target = payload["geo"]
117
+ best: tuple[float, dict[str, Any]] | None = None
118
+ for cand in candidates:
119
+ score = _similarity(target, _round_geo(cand, self._digits))
120
+ if best is None or score > best[0]:
121
+ best = (score, cand)
122
+ if best is None or best[0] < 0.5:
123
+ return None
124
+ return best[1]
125
+
126
+
127
+ def _similarity(a: dict[str, Any], b: dict[str, Any]) -> float:
128
+ """Fraction of shared keys whose values match. Deliberately simple and
129
+ explainable — identity heuristics should be auditable, not magical."""
130
+ keys = set(a) | set(b)
131
+ if not keys:
132
+ return 0.0
133
+ matches = sum(1 for k in keys if a.get(k) == b.get(k))
134
+ return matches / len(keys)
@@ -0,0 +1,31 @@
1
+ """The import honesty contract: every importer reports what happened."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class ImportReport:
10
+ """What an import actually did — machine-readable, never silent.
11
+
12
+ ``imported`` counts what came through; ``warnings`` are approximations the
13
+ user should verify (e.g. "outline approximated by bounding box");
14
+ ``dropped`` is data that did NOT survive the import. An import with drops
15
+ still succeeds — refusing entirely helps nobody — but the caller (and the
16
+ agent driving it) can see exactly what to check.
17
+ """
18
+
19
+ source: str
20
+ format: str
21
+ imported: dict[str, int] = field(default_factory=dict)
22
+ warnings: list[str] = field(default_factory=list)
23
+ dropped: list[str] = field(default_factory=list)
24
+
25
+ def count(self, kind: str, n: int = 1) -> None:
26
+ self.imported[kind] = self.imported.get(kind, 0) + n
27
+
28
+ def to_dict(self) -> dict:
29
+ return {"source": self.source, "format": self.format,
30
+ "imported": dict(sorted(self.imported.items())),
31
+ "warnings": self.warnings, "dropped": self.dropped}
@@ -0,0 +1,29 @@
1
+ """The Part standard — gitcad's cross-domain interchange contract (ADR-0008/9).
2
+
3
+ One unit across all domains: a Part is a manifest (domain-neutral) + a body
4
+ (domain-specific) + an interface (domain-neutral). An Assembly is a Part whose
5
+ body is composition. Versioning is interface-semver with a lockfile.
6
+
7
+ This package is pure Python, kernel-free, and CODEOWNERS-protected: it is the
8
+ most change-resistant surface gitcad owns.
9
+ """
10
+
11
+ from gitcad.part.assembly import Assembly, Instance, Mate
12
+ from gitcad.part.bought import assembly_bom, bought_part, is_bought
13
+ from gitcad.part.exploded import ExplodedView, auto_explode
14
+ from gitcad.part.matesolve import MateSolveReport, mate_solve
15
+ from gitcad.part.interference import check_interference
16
+ from gitcad.part.interface import Frame, Interface, Port, check_release, classify_change
17
+ from gitcad.part.lockfile import Lockfile, Workspace, resolve
18
+ from gitcad.part.manifest import PartManifest, new_part_id
19
+ from gitcad.part.semver import Version, satisfies
20
+
21
+ __all__ = [
22
+ "PartManifest", "new_part_id",
23
+ "Interface", "Frame", "Port", "classify_change", "check_release",
24
+ "Assembly", "Instance", "Mate", "check_interference",
25
+ "bought_part", "is_bought", "assembly_bom",
26
+ "ExplodedView", "auto_explode", "MateSolveReport", "mate_solve",
27
+ "Lockfile", "Workspace", "resolve",
28
+ "Version", "satisfies",
29
+ ]