thermodraw 1.0.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.
thermodraw/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """ThermoDraw — thermal network diagrams for Python.
2
+
3
+ A diagram is data. You write it, or an LLM writes it, and three pure stages
4
+ turn it into a document:
5
+
6
+ dict / JSON -> Diagram -> placements -> SVG
7
+ model layout render
8
+ (solve fills in the `at` you left out)
9
+
10
+ from thermodraw import Diagram, layout, render, save, theme
11
+
12
+ d = Diagram.from_json(open("diagram.json").read())
13
+ save(theme.with_variables(render(layout(d))), "out.svg")
14
+
15
+ `render` emits CSS custom properties with no fallback, so its output goes
16
+ through `theme` before it is saved: `with_variables` for the web, `bake` for
17
+ Word, slides and rasterisers. Without one of them the file draws nothing.
18
+
19
+ `DiagramBuilder` is sugar over the same data, and `symbols` still places one
20
+ symbol at a time for anything the model does not yet cover.
21
+
22
+ The schema is docs/schema.md. Design notes and the reasoning behind each
23
+ symbol are in CLAUDE.md.
24
+ """
25
+ from . import core, io, model, symbols, theme
26
+ from ._check import Finding, Report, check
27
+ from ._describe import Description, describe
28
+ from ._layout import Placement, layout
29
+ from ._page import page
30
+ from ._render import render
31
+ from ._solve import solve
32
+ from .builder import DiagramBuilder
33
+ from .io import save
34
+ from .model import Branch, Diagram, DiagramError, Node, Rail, Source
35
+ from .symbols import SYMBOLS, Symbol
36
+
37
+ __version__ = "1.0.0"
38
+ __all__ = [
39
+ # the data
40
+ "Diagram", "Node", "Branch", "Source", "Rail", "DiagramError",
41
+ # the pipeline
42
+ "solve", "layout", "render", "Placement", "DiagramBuilder",
43
+ # is it any good?
44
+ "check", "Report", "Finding",
45
+ # is it the one you meant?
46
+ "describe", "Description",
47
+ # the same drawing, as a page you can interact with
48
+ "page",
49
+ # the vocabulary
50
+ "Symbol", "SYMBOLS",
51
+ # output
52
+ "save", "theme",
53
+ # still public, for placing one symbol at a time
54
+ "symbols", "core", "model", "io",
55
+ ]
thermodraw/__main__.py ADDED
@@ -0,0 +1,262 @@
1
+ """ThermoDraw from the command line.
2
+
3
+ thermodraw check diagram.json
4
+ thermodraw describe diagram.json
5
+ thermodraw render diagram.json -o out.svg --mode light
6
+ thermodraw page diagram.json -o out.html
7
+ thermodraw solve diagram.json -o placed.json
8
+
9
+ python -m thermodraw check diagram.json # from a checkout, no install
10
+
11
+ `check` is the reason this exists, and `describe` is the half it could not
12
+ cover: the checker says nothing is wrong with the drawing and cannot say it is
13
+ the drawing you meant. `page` is the same drawing with its controls, for a
14
+ repeated group a reader may want to expand.
15
+
16
+ Finding out whether a diagram was any good
17
+ used to mean rendering it, serving it over HTTP, opening a browser, taking a
18
+ screenshot and looking — five sequential steps, none of which a script or a
19
+ model can do cheaply. This is one call whose output is text, and whose exit
20
+ status is the answer.
21
+
22
+ Exit codes: 0 clean, 1 findings, 2 no answer — the file could not be read or
23
+ was invalid. Only `check` can exit 1. 2 is also what argparse exits with for a
24
+ bad command line; a script that needs to tell the two apart has stderr.
25
+
26
+ Stdlib only. SVG and HTML; PNG needs a rasteriser, a rasteriser is a
27
+ dependency, and the library has none.
28
+ """
29
+ import argparse
30
+ import json
31
+ import pathlib
32
+ import sys
33
+
34
+ from . import theme
35
+ from ._check import ORDER, Finding, check
36
+ from ._describe import describe
37
+ from ._layout import layout
38
+ from ._page import page
39
+ from ._render import render
40
+ from ._solve import solve
41
+ from .io import DECLARATION, save
42
+ from .model import Diagram, DiagramError
43
+
44
+
45
+ def _die(message):
46
+ """Exit 2, which is neither "clean" nor "findings" but "no answer"."""
47
+ print(f"error: {message}", file=_soften(sys.stderr))
48
+ raise SystemExit(2)
49
+
50
+
51
+ def _load(path):
52
+ """The diagram at `path`, or exit 2 saying why."""
53
+ # `utf-8-sig`: PowerShell's Out-File and older Notepads write a BOM, and
54
+ # the file is no less UTF-8 for it. A file that is not UTF-8 at all used
55
+ # to escape as a traceback with exit 1 — the code reserved for "findings"
56
+ # — because UnicodeDecodeError is a ValueError, not an OSError.
57
+ try:
58
+ text = pathlib.Path(path).read_text(encoding="utf-8-sig")
59
+ except OSError as exc:
60
+ _die(f"cannot read {path}: {exc.strerror or exc}")
61
+ except UnicodeDecodeError as exc:
62
+ _die(f"{path} is not UTF-8: {exc.reason} at byte {exc.start}. "
63
+ "Save it as UTF-8")
64
+ try:
65
+ return Diagram.from_json(text)
66
+ except DiagramError as exc:
67
+ _die(f"{path}: {exc}")
68
+ except ValueError as exc: # not JSON at all
69
+ _die(f"{path} is not valid JSON: {exc}")
70
+
71
+
72
+ def _soften(stream):
73
+ """Let a cp1252 console print a diagram's own labels without dying.
74
+
75
+ Findings quote node ids, branch endpoints and units keys, which come from
76
+ the file and can hold anything. The fixed text is ASCII; this covers the
77
+ rest.
78
+
79
+ `backslashreplace`, not `replace`. The units quantity for a heat flux is
80
+ `q` followed by U+2033, and on a cp1252 console `replace` printed "units
81
+ names 'q?'" — an error naming a key the reader cannot copy, about a
82
+ character they most likely mistyped in the first place. This prints
83
+ 'q\\u2033', which is ugly and recoverable.
84
+ """
85
+ try:
86
+ stream.reconfigure(errors="backslashreplace")
87
+ except (AttributeError, ValueError): # not a real tty, or piped
88
+ pass
89
+ return stream
90
+
91
+
92
+ def _strict(report):
93
+ """Promote every note to a warning, so advice fails the run too."""
94
+ report.findings = [
95
+ f if f.severity != "note" else Finding(
96
+ f.code, "warning", f.where, f.message, f.remedy, f.at)
97
+ for f in report.findings]
98
+ report.findings.sort(key=lambda f: (ORDER[f.severity], f.code, f.where or ""))
99
+ return report
100
+
101
+
102
+ def do_check(args):
103
+ report = check(_load(args.diagram), size=args.size, source=args.diagram,
104
+ physics=args.physics)
105
+ if args.strict:
106
+ report = _strict(report)
107
+ out = _soften(sys.stdout)
108
+ if args.json:
109
+ json.dump(report.to_dict(), out, indent=2, ensure_ascii=False)
110
+ out.write("\n")
111
+ elif args.quiet:
112
+ for finding in report.findings:
113
+ print(finding.line(out.isatty()), file=out)
114
+ else:
115
+ print(report.text(colour=out.isatty()), file=out)
116
+ return 0 if report.ok else 1
117
+
118
+
119
+ def do_describe(args):
120
+ """What got drawn. Always exit 0: this reports, it does not judge."""
121
+ out = _soften(sys.stdout)
122
+ description = describe(_load(args.diagram), size=args.size,
123
+ source=args.diagram)
124
+ if args.json:
125
+ json.dump(description.to_dict(), out, indent=2, ensure_ascii=False)
126
+ out.write("\n")
127
+ else:
128
+ print(description.text(), file=out)
129
+ return 0
130
+
131
+
132
+ def do_page(args):
133
+ """The diagram as a page: the same SVG inline, plus its controls."""
134
+ diagram = _load(args.diagram)
135
+ out = page(diagram, size=args.size or diagram.size, title=args.title,
136
+ notation=args.notation)
137
+ if args.out == "-":
138
+ _soften(sys.stdout).write(out)
139
+ return 0
140
+ path = save(out, args.out or pathlib.Path(args.diagram).with_suffix(".html"))
141
+ print(f"{path} ({len(out):,} bytes)")
142
+ return 0
143
+
144
+
145
+ def do_render(args):
146
+ diagram = _load(args.diagram)
147
+ svg = render(layout(diagram), size=args.size or diagram.size,
148
+ notation=args.notation)
149
+ svg = theme.bake(svg, args.mode) if args.mode else theme.with_variables(svg)
150
+
151
+ # The same bytes to stdout as to a file. `save` is the library's one
152
+ # writer and adds the declaration; stdout gets it too. They used to
153
+ # differ, because this function typed the declaration out a second time.
154
+ if args.out == "-":
155
+ _soften(sys.stdout).write(DECLARATION + svg)
156
+ return 0
157
+ path = save(svg, args.out or pathlib.Path(args.diagram).with_suffix(".svg"))
158
+ print(f"{path} ({len(svg):,} bytes)")
159
+ return 0
160
+
161
+
162
+ def do_solve(args):
163
+ """The same diagram with every node placed, as JSON to edit from.
164
+
165
+ The workflow this exists for: write the network without coordinates,
166
+ solve it, then move what the solver put somewhere you would not have.
167
+ Everything the author set is kept; only the `at` that were missing are
168
+ added, and the `via` a parallel pair needed.
169
+ """
170
+ diagram = _load(args.diagram)
171
+ out = solve(diagram).to_json() + "\n"
172
+ if args.out == "-":
173
+ _soften(sys.stdout).write(out)
174
+ return 0
175
+ path = save(out, args.out or pathlib.Path(args.diagram).with_name(
176
+ pathlib.Path(args.diagram).stem + ".solved.json"))
177
+ print(f"{path} ({len(out):,} bytes)")
178
+ return 0
179
+
180
+
181
+ def main(argv=None):
182
+ ap = argparse.ArgumentParser(prog="thermodraw",
183
+ description=__doc__.split("\n\n")[0])
184
+ subs = ap.add_subparsers(dest="command", required=True)
185
+
186
+ def size(sub):
187
+ sub.add_argument("--size", nargs=2, type=float, metavar=("W", "H"),
188
+ help="fix the canvas instead of measuring it")
189
+
190
+ c = subs.add_parser("check", help="report what is wrong with a diagram")
191
+ c.add_argument("diagram")
192
+ c.add_argument("--json", action="store_true", help="machine-readable")
193
+ c.add_argument("--strict", action="store_true",
194
+ help="treat notes as warnings, so they fail too")
195
+ c.add_argument("--quiet", action="store_true",
196
+ help="findings only, no summary line; silent when there "
197
+ "is nothing at all to report")
198
+ c.add_argument("--physics", action="store_true",
199
+ help="also ask whether the stated numbers close at each "
200
+ "node")
201
+ size(c)
202
+ c.set_defaults(fn=do_check)
203
+
204
+ d = subs.add_parser("describe", help="say what the drawing contains")
205
+ d.add_argument("diagram")
206
+ d.add_argument("--json", action="store_true", help="machine-readable")
207
+ size(d)
208
+ d.set_defaults(fn=do_describe)
209
+
210
+ g = subs.add_parser("page", help="write the diagram as an HTML page")
211
+ g.add_argument("diagram")
212
+ g.add_argument("-o", "--out", help='output path, or "-" for stdout')
213
+ g.add_argument("--title", help="heading for the page")
214
+ g.add_argument("--notation", choices=["boxes", "zigzags"],
215
+ default="boxes",
216
+ help="draw resistances as textured boxes (default) or "
217
+ "as circuit zigzags")
218
+ size(g)
219
+ g.set_defaults(fn=do_page)
220
+
221
+ r = subs.add_parser("render", help="write the diagram as SVG")
222
+ r.add_argument("diagram")
223
+ r.add_argument("-o", "--out", help='output path, or "-" for stdout')
224
+ r.add_argument("--mode", choices=["light", "dark"],
225
+ help="bake the palette, for Word, slides and rasterisers")
226
+ r.add_argument("--notation", choices=["boxes", "zigzags"],
227
+ default="boxes",
228
+ help="draw resistances as textured boxes (default) or "
229
+ "as circuit zigzags")
230
+ size(r)
231
+ r.set_defaults(fn=do_render)
232
+
233
+ s = subs.add_parser("solve", help="write the diagram back with every "
234
+ "node placed, to edit from")
235
+ s.add_argument("diagram")
236
+ s.add_argument("-o", "--out", help='output path, or "-" for stdout')
237
+ s.set_defaults(fn=do_solve)
238
+
239
+ args = ap.parse_args(argv)
240
+ try:
241
+ return args.fn(args)
242
+ except SystemExit:
243
+ raise
244
+ except DiagramError as exc:
245
+ # A refusal from the solver comes out of `check`, `describe`,
246
+ # `render` and `page`, after `_load` has accepted the file. It is
247
+ # the library declining a diagram it does not place, which the
248
+ # schema documents; the net below called it a bug in thermodraw,
249
+ # in the same sentence as telling the reader what to type instead.
250
+ _die(str(exc))
251
+ except Exception as exc: # pragma: no cover - a net
252
+ # Exit 1 means findings. An uncaught exception used to reach the
253
+ # shell as 1 through Python's own handler, so a script gating on the
254
+ # status read a crash as a diagram with warnings. Every known cause
255
+ # is now refused by `validate`; this is the net under the unknown
256
+ # ones, and it says 2 -- no answer -- which is what a crash is.
257
+ _die(f"{type(exc).__name__}: {exc}. This is a bug in thermodraw; "
258
+ "the diagram was accepted and then could not be drawn")
259
+
260
+
261
+ if __name__ == "__main__":
262
+ raise SystemExit(main())