symdef 0.4.0__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.
symdef/__init__.py ADDED
@@ -0,0 +1,97 @@
1
+ """Standard-agnostic core for graphical symbols."""
2
+
3
+ from symdef._version import LIBRARY_VERSION
4
+ from symdef.boxes import body_box, keepout_box, slot_box
5
+ from symdef.build import load_bundle, load_library, stale_build, write_build
6
+ from symdef.errors import GraphicalSymbolsError, LibraryError, UnknownSymbolError
7
+ from symdef.geometry import (
8
+ Arc,
9
+ Box,
10
+ Circle,
11
+ Direction,
12
+ Element,
13
+ Fill,
14
+ Line,
15
+ Orientation,
16
+ Point,
17
+ Polyline,
18
+ Style,
19
+ Text,
20
+ Weight,
21
+ )
22
+ from symdef.lint import lint
23
+ from symdef.model import (
24
+ Allow,
25
+ Anchor,
26
+ Finding,
27
+ Library,
28
+ LibraryConfig,
29
+ Node,
30
+ Path,
31
+ PathKind,
32
+ Port,
33
+ Potential,
34
+ Reference,
35
+ Severity,
36
+ Slot,
37
+ Status,
38
+ Symbol,
39
+ SymbolKind,
40
+ )
41
+ from symdef.orient import orient, translate
42
+ from symdef.repeat import repeat
43
+ from symdef.svg import to_fragment, to_svg
44
+ from symdef.units import DEFAULT_MODULE_MM, GRID_DIVISION, on_grid, snap
45
+
46
+ __all__ = [
47
+ "DEFAULT_MODULE_MM",
48
+ "GRID_DIVISION",
49
+ "LIBRARY_VERSION",
50
+ "Allow",
51
+ "Anchor",
52
+ "Arc",
53
+ "Box",
54
+ "Circle",
55
+ "Direction",
56
+ "Element",
57
+ "Fill",
58
+ "Finding",
59
+ "GraphicalSymbolsError",
60
+ "Library",
61
+ "LibraryConfig",
62
+ "LibraryError",
63
+ "Line",
64
+ "Node",
65
+ "Orientation",
66
+ "Path",
67
+ "PathKind",
68
+ "Point",
69
+ "Polyline",
70
+ "Port",
71
+ "Potential",
72
+ "Reference",
73
+ "Severity",
74
+ "Slot",
75
+ "Status",
76
+ "Style",
77
+ "Symbol",
78
+ "SymbolKind",
79
+ "Text",
80
+ "UnknownSymbolError",
81
+ "Weight",
82
+ "body_box",
83
+ "keepout_box",
84
+ "lint",
85
+ "load_bundle",
86
+ "load_library",
87
+ "on_grid",
88
+ "orient",
89
+ "repeat",
90
+ "slot_box",
91
+ "snap",
92
+ "stale_build",
93
+ "to_fragment",
94
+ "to_svg",
95
+ "translate",
96
+ "write_build",
97
+ ]
symdef/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The package version, written by scripts/build.py. Do not edit."""
2
+
3
+ LIBRARY_VERSION = "0.4.0"
symdef/boxes.py ADDED
@@ -0,0 +1,112 @@
1
+ """The body, slot and keep-out boxes of a symbol."""
2
+
3
+ import math
4
+
5
+ import deal
6
+
7
+ from symdef.geometry import (
8
+ Arc,
9
+ Box,
10
+ Circle,
11
+ Direction,
12
+ Element,
13
+ Line,
14
+ Point,
15
+ Polyline,
16
+ Text,
17
+ arc_point,
18
+ arc_sweep,
19
+ )
20
+ from symdef.model import Slot, Symbol
21
+
22
+ _AXIS_ANGLES = (0, 90, 180, 270)
23
+ _FULL_TURN = 360
24
+ _TEXT_WIDTH_PER_HEIGHT = 0.6
25
+ _ORIGIN_BOX = Box(Point(0, 0), Point(0, 0))
26
+
27
+
28
+ @deal.pure
29
+ def _extent_points(element: Element) -> tuple[Point, ...]:
30
+ """Return points whose bounding box is the element's extent, ignoring stroke width.
31
+
32
+ An arc with a non-finite angle has no swept extent and counts as its centre.
33
+ """
34
+ match element:
35
+ case Line(start=start, end=end):
36
+ return (start, end)
37
+ case Polyline(points=points):
38
+ return points
39
+ case Circle(center=c, radius=r):
40
+ return (Point(c.x - r, c.y - r), Point(c.x + r, c.y + r))
41
+ case Arc() as arc:
42
+ if not (math.isfinite(arc.start_deg) and math.isfinite(arc.end_deg)):
43
+ return (arc.center,)
44
+ start, end = arc.start_deg % _FULL_TURN, arc.end_deg % _FULL_TURN
45
+ sweep = arc_sweep(arc)
46
+ extremes = tuple(a for a in _AXIS_ANGLES if (a - start) % _FULL_TURN <= sweep)
47
+ return tuple(arc_point(arc, a) for a in (start, end, *extremes))
48
+ case Text(content=content, position=at, height=height):
49
+ half_w = _TEXT_WIDTH_PER_HEIGHT * height * len(content) / 2
50
+ return (
51
+ Point(at.x - half_w, at.y - height / 2),
52
+ Point(at.x + half_w, at.y + height / 2),
53
+ )
54
+
55
+
56
+ @deal.pure
57
+ def _bounding(points: tuple[Point, ...]) -> Box:
58
+ """Return the smallest box holding every point; no points give the origin box."""
59
+ if not points:
60
+ return _ORIGIN_BOX
61
+ xs = [p.x for p in points]
62
+ ys = [p.y for p in points]
63
+ return Box(Point(min(xs), min(ys)), Point(max(xs), max(ys)))
64
+
65
+
66
+ @deal.pure
67
+ def element_box(element: Element) -> Box:
68
+ """Return the extent of one element.
69
+
70
+ A line or polyline extends over its points, a circle over its full diameter, an arc over its
71
+ true swept extent, and text is a box centred on its position, `height` tall and `0.6 * height`
72
+ per character wide. Stroke width is ignored. A polyline without points has the origin box.
73
+ """
74
+ return _bounding(_extent_points(element))
75
+
76
+
77
+ @deal.pure
78
+ def body_box(symbol: Symbol) -> Box:
79
+ """Return the union of the element extents, in module units, ignoring stroke width.
80
+
81
+ Extents are as for text (`0.6 * height` per character), arcs (the true swept extent) and the
82
+ other elements. A symbol without elements has the box (0, 0) to (0, 0). Ports, anchors and
83
+ slots do not count.
84
+ """
85
+ return _bounding(tuple(p for e in symbol.elements for p in _extent_points(e)))
86
+
87
+
88
+ @deal.pure
89
+ def slot_box(slot: Slot) -> Box:
90
+ """Return the box a slot reserves: it grows from the slot point towards its side.
91
+
92
+ E and W grow horizontally and are vertically centred; N grows up and S grows down and both
93
+ are horizontally centred. The box is never rotated; only the point and side are.
94
+ """
95
+ x, y = slot.position.x, slot.position.y
96
+ w, h = slot.box
97
+ match slot.side:
98
+ case Direction.E:
99
+ return Box(Point(x, y - h / 2), Point(x + w, y + h / 2))
100
+ case Direction.W:
101
+ return Box(Point(x - w, y - h / 2), Point(x, y + h / 2))
102
+ case Direction.N:
103
+ return Box(Point(x - w / 2, y - h), Point(x + w / 2, y))
104
+ case Direction.S:
105
+ return Box(Point(x - w / 2, y), Point(x + w / 2, y + h))
106
+
107
+
108
+ @deal.pure
109
+ def keepout_box(symbol: Symbol) -> Box:
110
+ """Return the body box united with every slot box: the room the symbol needs kept clear."""
111
+ boxes = (body_box(symbol), *(slot_box(s) for s in symbol.slots))
112
+ return _bounding(tuple(p for b in boxes for p in (b.min, b.max)))
symdef/build.py ADDED
@@ -0,0 +1,207 @@
1
+ """The impure shell: everything that reads or writes files. Pure work stays in the other modules."""
2
+
3
+ from dataclasses import replace
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from symdef.errors import LibraryError
8
+ from symdef.lint.registry import rule_finding
9
+ from symdef.load import (
10
+ is_file_stem,
11
+ library_from_bundle,
12
+ parse_config,
13
+ parse_json,
14
+ parse_toml,
15
+ validate_bundle,
16
+ )
17
+ from symdef.model import Finding, Library, LibraryConfig
18
+ from symdef.resolve import resolve_library
19
+ from symdef.serialize import GENERATED_DIRS, build_files, package_folder
20
+
21
+
22
+ def _named(name: str, findings: tuple[Finding, ...]) -> tuple[Finding, ...]:
23
+ """Prefix each message with the file it is about, since a finding has no file of its own."""
24
+ return tuple(replace(f, message=f"{name}: {f.message}") for f in findings)
25
+
26
+
27
+ def _read(path: Path) -> tuple[str | None, tuple[Finding, ...]]:
28
+ """Read a UTF-8 text file; one that cannot be read is a `schema` finding located at its name."""
29
+ try:
30
+ return path.read_text(encoding="utf-8"), ()
31
+ except (OSError, UnicodeDecodeError) as error:
32
+ return None, _named(path.name, (rule_finding("schema", str(error), path.name),))
33
+
34
+
35
+ def _read_config(path: Path) -> tuple[LibraryConfig | None, tuple[Finding, ...]]:
36
+ """Read `library.toml`."""
37
+ text, found = _read(path)
38
+ if text is None:
39
+ return None, found
40
+ config, problems = parse_config(text)
41
+ return config, _named(path.name, problems)
42
+
43
+
44
+ def _read_symbol(path: Path) -> tuple[dict[str, Any] | None, tuple[Finding, ...]]:
45
+ """Read one symbol file's TOML."""
46
+ text, found = _read(path)
47
+ if text is None:
48
+ return None, found
49
+ data, problems = parse_toml(text)
50
+ return data, _named(path.name, problems)
51
+
52
+
53
+ def load_library(root: Path) -> Library:
54
+ """Read `library.toml` and `symbols/*.toml` under `root`, validate, resolve and flatten them.
55
+
56
+ Args:
57
+ root: The data repo's directory.
58
+
59
+ Returns:
60
+ The library, its symbols keyed by reference number.
61
+
62
+ Raises:
63
+ LibraryError: If a file cannot be read or fails any check. `findings` holds every
64
+ finding, `library.toml` first and then the symbol files in name order, each message
65
+ prefixed with its file name.
66
+ """
67
+ config, problems = _read_config(root / "library.toml")
68
+ directory = root / "symbols"
69
+ if not directory.is_dir():
70
+ missing = rule_finding("schema", "the directory does not exist", directory.name)
71
+ raise LibraryError((*problems, *_named(directory.name, (missing,))))
72
+ per_file: dict[str, tuple[Finding, ...]] = {}
73
+ sources: dict[str, dict[str, Any]] = {}
74
+ for path in sorted(directory.glob("*.toml"), key=lambda p: p.stem):
75
+ data, found = _read_symbol(path)
76
+ if data is None:
77
+ per_file[path.stem] = found
78
+ else:
79
+ sources[path.stem] = data
80
+ if config is None:
81
+ raise LibraryError((*problems, *(f for stem in sorted(per_file) for f in per_file[stem])))
82
+ resolution = resolve_library(config, sources)
83
+ per_file |= {stem: _named(f"{stem}.toml", found) for stem, found in resolution.findings.items()}
84
+ problems += tuple(f for stem in sorted(per_file) for f in per_file[stem])
85
+ if problems:
86
+ raise LibraryError(problems)
87
+ return Library(config.standard, config.title, config.number_pattern, resolution.symbols)
88
+
89
+
90
+ def load_bundle(json_path: Path) -> Library:
91
+ """Read a resolved bundle (`bundle.json`) into a library.
92
+
93
+ The library has the bundle's standard for both `standard` and `title`, and no number pattern
94
+ (D7). Symbols are checked like a source file, plus the resolved form (`validate_bundle`).
95
+
96
+ Args:
97
+ json_path: The bundle file.
98
+
99
+ Returns:
100
+ The library, its symbols keyed by the bundle's keys.
101
+
102
+ Raises:
103
+ LibraryError: If the file cannot be read, is not JSON or is not a valid bundle. Every
104
+ finding is a `schema` finding, its message prefixed with the file name.
105
+ """
106
+ text, found = _read(json_path)
107
+ if text is None:
108
+ raise LibraryError(found)
109
+ data, problems = parse_json(text)
110
+ if not problems:
111
+ problems = validate_bundle(data)
112
+ if problems:
113
+ raise LibraryError(_named(json_path.name, problems))
114
+ return library_from_bundle(data)
115
+
116
+
117
+ def _package_key(root: Path) -> str:
118
+ """Return the `package` key of `root/library.toml`, or `""` when it is absent or unreadable.
119
+
120
+ `load_library` is where a bad `library.toml` is reported; a build only reads the one key.
121
+ """
122
+ config, _ = _read_config(root / "library.toml")
123
+ return config.package if config else ""
124
+
125
+
126
+ def _check_names(library: Library, package: str) -> None:
127
+ """Refuse a library that would need an unsafe file or directory name, before any file is made.
128
+
129
+ A number must be a file stem, and the standard must give a package folder (D8, D35, D45).
130
+ """
131
+ for number in library.symbols:
132
+ if not is_file_stem(number):
133
+ msg = f"symbol number {number!r} cannot be used as a file name"
134
+ raise ValueError(msg)
135
+ if not package_folder(library.standard, package):
136
+ msg = f"the standard {library.standard!r} has no letter or digit to make a package name of"
137
+ raise ValueError(msg)
138
+
139
+
140
+ def write_build(library: Library, root: Path) -> tuple[Path, ...]:
141
+ """Write the build of a library under a data repo: `build/` and `src/<package>/bundle.json`.
142
+
143
+ `<package>` is the `package` key of `root/library.toml` when it has one, else made from the
144
+ standard (D8, D45).
145
+
146
+ Files are written as bytes, so line endings are LF on every platform, and missing directories
147
+ are created. Nothing is deleted: a file that an earlier build wrote and this one does not
148
+ (a removed symbol) stays, and `stale_build` reports it; removing it is the caller's job.
149
+
150
+ Args:
151
+ library: The library to build.
152
+ root: The data repo's directory.
153
+
154
+ Returns:
155
+ Every path written, as `root / <relative path>` (so relative if `root` is), in the order
156
+ of their `/`-separated relative paths.
157
+
158
+ Raises:
159
+ ValueError: If a symbol number is not a file stem (`is_file_stem`) or the standard has no
160
+ letter or digit to make a package name of; nothing is written.
161
+ OSError: If a path cannot be written; files already written stay.
162
+ """
163
+ package = _package_key(root)
164
+ _check_names(library, package)
165
+ written = []
166
+ for relative, data in build_files(library, package).items():
167
+ target = root / relative
168
+ target.parent.mkdir(parents=True, exist_ok=True)
169
+ target.write_bytes(data)
170
+ written.append(target)
171
+ return tuple(written)
172
+
173
+
174
+ def stale_build(library: Library, root: Path) -> tuple[Path, ...]:
175
+ """Return the files under `root` that differ from what `write_build` would write.
176
+
177
+ A file is stale if it is missing or has other bytes, or if it sits anywhere under
178
+ `build/resolved`, `build/svg` or `build/annotated` and the library does not produce it.
179
+ Nothing else under `root` is looked at.
180
+
181
+ Args:
182
+ library: The library the build should match.
183
+ root: The data repo's directory.
184
+
185
+ Returns:
186
+ The stale paths, as `root / <relative path>` (so relative if `root` is), in the order of
187
+ their `/`-separated relative paths.
188
+
189
+ Raises:
190
+ ValueError: If a symbol number is not a file stem (`is_file_stem`) or the standard has no
191
+ letter or digit to make a package name of.
192
+ OSError: If a file cannot be read; it is not treated as stale.
193
+ """
194
+ package = _package_key(root)
195
+ _check_names(library, package)
196
+ files = build_files(library, package)
197
+ stale = {
198
+ relative
199
+ for relative, data in files.items()
200
+ if not (root / relative).is_file() or (root / relative).read_bytes() != data
201
+ }
202
+ for directory in GENERATED_DIRS:
203
+ for path in (root / directory).rglob("*"):
204
+ relative = path.relative_to(root).as_posix()
205
+ if path.is_file() and relative not in files:
206
+ stale.add(relative)
207
+ return tuple(root / relative for relative in sorted(stale))