sketchforge3d 1.2.1__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.
@@ -0,0 +1,105 @@
1
+ """
2
+ A client for the SketchForge REST API, and a runner for macros written against it.
3
+
4
+ from sketchforge3d import client_from_environment
5
+
6
+ client = client_from_environment()
7
+ project = client.project(client.projects.create("Bracket").id)
8
+ project.add({"kind": "box", "width": 60, "depth": 40, "height": 8})
9
+
10
+ Installed as ``sketchforge3d``; imported the same way.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from . import layout
16
+ from .client import Edges, ProjectHandle, Projects, Shapes, SketchForgeClient, Treatments, chunk
17
+ from .environment import (
18
+ BASE_URL_ENV,
19
+ DEFAULT_BASE_URL,
20
+ PROJECT_ENV,
21
+ TOKEN_ENV,
22
+ client_from_environment,
23
+ target_project_from_environment,
24
+ )
25
+ from .errors import (
26
+ MissingConfigurationError,
27
+ SketchForgeApiError,
28
+ SketchForgeError,
29
+ SketchForgeTransportError,
30
+ )
31
+ from .layout import Point2, angle_of, centered_offsets, grid, mm, ring, row
32
+ from ._macro import (
33
+ PROJECT_MODES,
34
+ Macro,
35
+ MacroContext,
36
+ MacroRunReport,
37
+ define_macro,
38
+ load_macro_file,
39
+ resolve_macro_params,
40
+ resolve_macro_project,
41
+ run_macro,
42
+ run_stamp,
43
+ )
44
+ from .types import (
45
+ MAX_SHAPES_PER_REQUEST,
46
+ PARAMETRIC_SHAPE_KINDS,
47
+ SHAPE_KINDS,
48
+ THREAD_DESIGNATIONS,
49
+ EdgeSummary,
50
+ ProjectDetail,
51
+ ProjectSummary,
52
+ ShapeSummary,
53
+ ShapeWriteResult,
54
+ TreatmentResult,
55
+ )
56
+
57
+ __version__ = "0.1.0"
58
+
59
+ __all__ = [
60
+ "BASE_URL_ENV",
61
+ "DEFAULT_BASE_URL",
62
+ "Edges",
63
+ "EdgeSummary",
64
+ "MAX_SHAPES_PER_REQUEST",
65
+ "Macro",
66
+ "MacroContext",
67
+ "MacroRunReport",
68
+ "MissingConfigurationError",
69
+ "PARAMETRIC_SHAPE_KINDS",
70
+ "PROJECT_ENV",
71
+ "PROJECT_MODES",
72
+ "Point2",
73
+ "ProjectDetail",
74
+ "ProjectHandle",
75
+ "ProjectSummary",
76
+ "Projects",
77
+ "SHAPE_KINDS",
78
+ "Shapes",
79
+ "ShapeSummary",
80
+ "ShapeWriteResult",
81
+ "SketchForgeApiError",
82
+ "SketchForgeClient",
83
+ "SketchForgeError",
84
+ "SketchForgeTransportError",
85
+ "THREAD_DESIGNATIONS",
86
+ "TOKEN_ENV",
87
+ "TreatmentResult",
88
+ "Treatments",
89
+ "angle_of",
90
+ "centered_offsets",
91
+ "chunk",
92
+ "client_from_environment",
93
+ "define_macro",
94
+ "grid",
95
+ "layout",
96
+ "load_macro_file",
97
+ "mm",
98
+ "resolve_macro_params",
99
+ "resolve_macro_project",
100
+ "ring",
101
+ "row",
102
+ "run_macro",
103
+ "run_stamp",
104
+ "target_project_from_environment",
105
+ ]
@@ -0,0 +1,6 @@
1
+ """``python -m sketchforge3d <file>``, the same runner as ``sketchforge-macro``."""
2
+
3
+ from ._macro import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
sketchforge3d/_http.py ADDED
@@ -0,0 +1,117 @@
1
+ """
2
+ The one place that talks HTTP, over ``urllib`` so the package has no dependencies.
3
+
4
+ Ids travel in the query string rather than the path, which is a constraint of
5
+ the server's static-export build rather than a style choice, so every call goes
6
+ through ``query`` and no resource builds a path by hand.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ from typing import Any, Dict, Mapping, Optional
17
+
18
+ from .errors import SketchForgeApiError, SketchForgeTransportError
19
+
20
+ DEFAULT_TIMEOUT_SECONDS = 60.0
21
+ DEFAULT_RETRY_ON_BUSY = 4
22
+ _BUSY_RETRY_BASE_SECONDS = 0.06
23
+
24
+
25
+ def normalize_base_url(base_url: str) -> str:
26
+ """``http://host:3000``, with or without a trailing slash or the API prefix."""
27
+ trimmed = base_url.strip().rstrip("/")
28
+ if not trimmed:
29
+ raise ValueError("A base_url is required, for example http://localhost:3000.")
30
+ return trimmed if trimmed.endswith("/api/v1") else trimmed + "/api/v1"
31
+
32
+
33
+ def build_url(root: str, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
34
+ pairs = [(key, str(value)) for key, value in (query or {}).items() if value is not None]
35
+ suffix = "?" + urllib.parse.urlencode(pairs) if pairs else ""
36
+ return root + path + suffix
37
+
38
+
39
+ class Transport:
40
+ """Sends one request and parses one answer, retrying only a lost project lock."""
41
+
42
+ def __init__(
43
+ self,
44
+ base_url: str,
45
+ token: str,
46
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
47
+ retry_on_busy: int = DEFAULT_RETRY_ON_BUSY,
48
+ headers: Optional[Mapping[str, str]] = None,
49
+ opener: Optional[Any] = None,
50
+ ) -> None:
51
+ self.api_root = normalize_base_url(base_url)
52
+ self._token = token
53
+ self._timeout = timeout
54
+ self._retry_on_busy = retry_on_busy
55
+ self._headers = dict(headers or {})
56
+ #: Injectable so tests need no socket; anything with ``open(request, timeout=)``.
57
+ self._opener = opener or urllib.request.build_opener()
58
+
59
+ def send(
60
+ self,
61
+ method: str,
62
+ path: str,
63
+ query: Optional[Mapping[str, Any]] = None,
64
+ body: Optional[Any] = None,
65
+ retryable: bool = False,
66
+ ) -> Any:
67
+ url = build_url(self.api_root, path, query)
68
+ attempts = self._retry_on_busy + 1 if retryable else 1
69
+
70
+ for attempt in range(1, attempts + 1):
71
+ try:
72
+ return self._send_once(method, url, body)
73
+ except SketchForgeApiError as error:
74
+ # A locked *shape* also answers 409 and must not be retried,
75
+ # which is why the message rather than the status decides.
76
+ if not error.is_project_busy or attempt == attempts:
77
+ raise
78
+ time.sleep(_BUSY_RETRY_BASE_SECONDS * attempt)
79
+ raise AssertionError("unreachable")
80
+
81
+ def _send_once(self, method: str, url: str, body: Optional[Any]) -> Any:
82
+ headers: Dict[str, str] = dict(self._headers)
83
+ headers["Authorization"] = "Bearer " + self._token
84
+ headers["Accept"] = "application/json"
85
+ payload = None
86
+ if body is not None:
87
+ payload = json.dumps(body).encode("utf-8")
88
+ headers["Content-Type"] = "application/json"
89
+
90
+ request = urllib.request.Request(url, data=payload, headers=headers, method=method)
91
+ try:
92
+ with self._opener.open(request, timeout=self._timeout) as response:
93
+ return _decode(response.read())
94
+ except urllib.error.HTTPError as error:
95
+ parsed = _decode(error.read())
96
+ detail = parsed.get("error") if isinstance(parsed, dict) else None
97
+ raise SketchForgeApiError(
98
+ method,
99
+ url,
100
+ error.code,
101
+ detail if isinstance(detail, str) else None,
102
+ parsed,
103
+ ) from None
104
+ except urllib.error.URLError as error:
105
+ raise SketchForgeTransportError(method, url, error) from None
106
+ except OSError as error:
107
+ raise SketchForgeTransportError(method, url, error) from None
108
+
109
+
110
+ def _decode(raw: bytes) -> Any:
111
+ if not raw:
112
+ return None
113
+ text = raw.decode("utf-8", errors="replace")
114
+ try:
115
+ return json.loads(text)
116
+ except ValueError:
117
+ return text
@@ -0,0 +1,357 @@
1
+ """
2
+ Macros: a script that is handed a connected project and builds something in it.
3
+
4
+ The hard part of a macro is not the geometry, it is what happens the second time
5
+ it runs. A script that appends shapes to a project it found is a script that
6
+ doubles the part on every run, and the mistake is invisible until someone opens
7
+ the project. So a macro cannot choose a project implicitly: it declares a
8
+ project mode, and the runner -- not the macro body -- does what that mode says
9
+ before the macro sees anything.
10
+
11
+ fresh a new project every run, named uniquely. Nothing is ever reused,
12
+ so a run can never corrupt an earlier one.
13
+ replace one project of that name, emptied first. Re-running gives the same
14
+ result, which is what "idempotent" means for a build script.
15
+ reuse one project of that name, left as it is. For a macro that adds a
16
+ feature to whatever is already there; it is the caller's problem
17
+ if that runs twice.
18
+ target the project id the operator names, never created and never
19
+ cleared. Refuses to run without one.
20
+
21
+ Run one with ``python -m sketchforge3d.macro <file> [--name=value ...]``. The
22
+ file is imported from a path the person running the command typed; nothing here
23
+ accepts code over a socket.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import datetime as _datetime
30
+ import importlib.util
31
+ import json
32
+ import os
33
+ import sys
34
+ import time
35
+ from dataclasses import dataclass, field
36
+ from types import ModuleType
37
+ from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Union
38
+
39
+ from . import layout as layout_module
40
+ from .client import ProjectHandle, SketchForgeClient
41
+ from .environment import PROJECT_ENV, client_from_environment, target_project_from_environment
42
+ from .errors import SketchForgeApiError, SketchForgeError, SketchForgeTransportError
43
+
44
+ MacroParameterValue = Union[int, float, str, bool]
45
+
46
+ PROJECT_MODES = ("fresh", "replace", "reuse", "target")
47
+
48
+
49
+ @dataclass
50
+ class MacroContext:
51
+ client: SketchForgeClient
52
+ #: The project the mode resolved to, already created or emptied as declared.
53
+ project: ProjectHandle
54
+ params: Dict[str, MacroParameterValue]
55
+ #: The layout arithmetic, handed to the macro rather than imported by it.
56
+ layout: ModuleType
57
+ #: Progress, on stderr, so a macro's own result can be piped.
58
+ log: Callable[[str], None]
59
+
60
+
61
+ @dataclass
62
+ class Macro:
63
+ name: str
64
+ run: Callable[[MacroContext], Any]
65
+ #: One of PROJECT_MODES, optionally as ``(mode, project name)``.
66
+ project: Union[str, Sequence[str]] = "fresh"
67
+ description: str = ""
68
+ #: Defaults for every parameter the macro takes. They double as the schema:
69
+ #: an override is coerced to the type of the default, and an undeclared name
70
+ #: is refused rather than passed through as a string the macro misreads.
71
+ params: Dict[str, MacroParameterValue] = field(default_factory=dict)
72
+
73
+ def __post_init__(self) -> None:
74
+ mode, _ = _project_spec(self.project)
75
+ if mode not in PROJECT_MODES:
76
+ raise ValueError(
77
+ 'A macro\'s project must be one of "' + '", "'.join(PROJECT_MODES) + f'", not "{mode}".'
78
+ )
79
+
80
+
81
+ def define_macro(
82
+ name: str,
83
+ run: Callable[[MacroContext], Any],
84
+ project: Union[str, Sequence[str]] = "fresh",
85
+ description: str = "",
86
+ params: Optional[Mapping[str, MacroParameterValue]] = None,
87
+ ) -> Macro:
88
+ return Macro(
89
+ name=name,
90
+ run=run,
91
+ project=project,
92
+ description=description,
93
+ params=dict(params or {}),
94
+ )
95
+
96
+
97
+ def _project_spec(project: Union[str, Sequence[str]]) -> "tuple[str, Optional[str]]":
98
+ if isinstance(project, str):
99
+ return project, None
100
+ parts = list(project)
101
+ return (parts[0] if parts else "fresh"), (parts[1] if len(parts) > 1 else None)
102
+
103
+
104
+ def run_stamp(now: Optional[_datetime.datetime] = None) -> str:
105
+ """A stamp that sorts chronologically and survives a project name: ``20260102-114530``."""
106
+ moment = now or _datetime.datetime.now(_datetime.timezone.utc)
107
+ return moment.strftime("%Y%m%d-%H%M%S")
108
+
109
+
110
+ @dataclass
111
+ class ResolvedProject:
112
+ project: ProjectHandle
113
+ created: bool
114
+ cleared: int
115
+
116
+
117
+ def resolve_macro_project(
118
+ client: SketchForgeClient,
119
+ macro: Macro,
120
+ target_project_id: Optional[str] = None,
121
+ now: Optional[_datetime.datetime] = None,
122
+ ) -> ResolvedProject:
123
+ """
124
+ The project a macro runs in, per its declared mode.
125
+
126
+ ``replace`` and ``reuse`` look the project up by name, which the API does not
127
+ index -- so they list every project and match. That is fine for the handful
128
+ a workshop has and would not be for thousands.
129
+ """
130
+ mode, declared_name = _project_spec(macro.project)
131
+ wanted_name = declared_name or macro.name
132
+
133
+ if mode == "target":
134
+ if not target_project_id:
135
+ raise SketchForgeError(
136
+ f'Macro "{macro.name}" runs in an existing project: set {PROJECT_ENV} or pass --project.'
137
+ )
138
+ # Read it before the macro starts, so a wrong id fails as a 404 here
139
+ # rather than halfway through a part.
140
+ client.projects.get(target_project_id)
141
+ return ResolvedProject(project=client.project(target_project_id), created=False, cleared=0)
142
+
143
+ if mode == "fresh":
144
+ created = client.projects.create(f"{wanted_name} {run_stamp(now)}")
145
+ return ResolvedProject(project=client.project(created.id), created=True, cleared=0)
146
+
147
+ existing = client.projects.find_by_name(wanted_name)
148
+ if existing is None:
149
+ created = client.projects.create(wanted_name)
150
+ return ResolvedProject(project=client.project(created.id), created=True, cleared=0)
151
+
152
+ handle = client.project(existing.id)
153
+ cleared = handle.clear() if mode == "replace" else 0
154
+ return ResolvedProject(project=handle, created=False, cleared=cleared)
155
+
156
+
157
+ _TRUE = ("true", "1", "yes")
158
+ _FALSE = ("false", "0", "no")
159
+
160
+
161
+ def resolve_macro_params(
162
+ defaults: Mapping[str, MacroParameterValue],
163
+ overrides: Mapping[str, str],
164
+ ) -> Dict[str, MacroParameterValue]:
165
+ """A macro's parameters, with overrides coerced to the type of each default."""
166
+ resolved: Dict[str, MacroParameterValue] = dict(defaults)
167
+ for key, raw in overrides.items():
168
+ if key not in resolved:
169
+ known = ", ".join(sorted(resolved)) or "no parameters"
170
+ raise SketchForgeError(f'Unknown parameter "{key}". This macro takes: {known}.')
171
+ fallback = resolved[key]
172
+ if isinstance(fallback, bool):
173
+ if raw.lower() not in _TRUE + _FALSE:
174
+ raise SketchForgeError(f'Parameter "{key}" must be true or false, not "{raw}".')
175
+ resolved[key] = raw.lower() in _TRUE
176
+ elif isinstance(fallback, int):
177
+ try:
178
+ resolved[key] = int(raw, 10)
179
+ except ValueError:
180
+ raise SketchForgeError(f'Parameter "{key}" must be a whole number, not "{raw}".') from None
181
+ elif isinstance(fallback, float):
182
+ try:
183
+ resolved[key] = float(raw)
184
+ except ValueError:
185
+ raise SketchForgeError(f'Parameter "{key}" must be a number, not "{raw}".') from None
186
+ else:
187
+ resolved[key] = raw
188
+ return resolved
189
+
190
+
191
+ @dataclass
192
+ class MacroRunReport:
193
+ macro: str
194
+ project_id: str
195
+ project_created: bool
196
+ shapes_cleared: int
197
+ shape_count: int
198
+ duration_ms: int
199
+ result: Any
200
+
201
+ def as_dict(self) -> Dict[str, Any]:
202
+ return {
203
+ "macro": self.macro,
204
+ "projectId": self.project_id,
205
+ "projectCreated": self.project_created,
206
+ "shapesCleared": self.shapes_cleared,
207
+ "shapeCount": self.shape_count,
208
+ "durationMs": self.duration_ms,
209
+ "result": self.result,
210
+ }
211
+
212
+
213
+ def run_macro(
214
+ client: SketchForgeClient,
215
+ macro: Macro,
216
+ params: Optional[Mapping[str, str]] = None,
217
+ target_project_id: Optional[str] = None,
218
+ log: Optional[Callable[[str], None]] = None,
219
+ now: Optional[_datetime.datetime] = None,
220
+ ) -> MacroRunReport:
221
+ """Runs a macro and reports what it did to the project, as the CLI prints it."""
222
+ emit = log or (lambda message: None)
223
+ resolved_params = resolve_macro_params(macro.params, params or {})
224
+ started = time.monotonic()
225
+ resolved = resolve_macro_project(client, macro, target_project_id=target_project_id, now=now)
226
+
227
+ if resolved.created:
228
+ emit(f"project {resolved.project.id} created")
229
+ if resolved.cleared:
230
+ emit(f"{resolved.cleared} existing shape(s) removed")
231
+
232
+ result = macro.run(
233
+ MacroContext(
234
+ client=client,
235
+ project=resolved.project,
236
+ params=resolved_params,
237
+ layout=layout_module,
238
+ log=emit,
239
+ )
240
+ )
241
+ shapes = resolved.project.list_shapes()
242
+
243
+ return MacroRunReport(
244
+ macro=macro.name,
245
+ project_id=resolved.project.id,
246
+ project_created=resolved.created,
247
+ shapes_cleared=resolved.cleared,
248
+ shape_count=len(shapes),
249
+ duration_ms=int((time.monotonic() - started) * 1000),
250
+ result=result,
251
+ )
252
+
253
+
254
+ def macro_from_module(module: Any, fallback_name: str) -> Macro:
255
+ """
256
+ A macro module's export, whichever way it was written.
257
+
258
+ ``MACRO = define_macro(...)`` is the documented form; a module with a bare
259
+ ``run`` function is accepted as the smallest thing that can work, and runs in
260
+ a fresh project because that is the only mode safe to assume.
261
+ """
262
+ candidate = getattr(module, "MACRO", None) or getattr(module, "macro", None)
263
+ if isinstance(candidate, Macro):
264
+ return candidate if candidate.name else define_macro(fallback_name, candidate.run)
265
+
266
+ runner = getattr(module, "run", None)
267
+ if callable(runner):
268
+ return define_macro(fallback_name, runner, project="fresh")
269
+
270
+ raise SketchForgeError(
271
+ f"{fallback_name} does not look like a macro: set MACRO = define_macro(...), "
272
+ "or define run(context)."
273
+ )
274
+
275
+
276
+ def load_macro_file(path: str) -> Macro:
277
+ """Imports a macro file by path, under its own module name."""
278
+ absolute = os.path.abspath(path)
279
+ if not os.path.isfile(absolute):
280
+ raise SketchForgeError(f"No macro file at {absolute}.")
281
+ stem = os.path.splitext(os.path.basename(absolute))[0]
282
+ spec = importlib.util.spec_from_file_location(f"sketchforge_macro_{stem.replace('-', '_')}", absolute)
283
+ if spec is None or spec.loader is None:
284
+ raise SketchForgeError(f"{absolute} could not be imported as a Python module.")
285
+ module = importlib.util.module_from_spec(spec)
286
+ spec.loader.exec_module(module)
287
+ return macro_from_module(module, stem)
288
+
289
+
290
+ def parse_parameters(pairs: Sequence[str]) -> Dict[str, str]:
291
+ """``--count=5`` style overrides, as argparse leaves them."""
292
+ parsed: Dict[str, str] = {}
293
+ for pair in pairs:
294
+ name, separator, value = pair.lstrip("-").partition("=")
295
+ if not separator or not name:
296
+ raise SketchForgeError(f'Expected --name=value, got "{pair}".')
297
+ parsed[name] = value
298
+ return parsed
299
+
300
+
301
+ def describe_report(report: MacroRunReport) -> str:
302
+ lines = [
303
+ f"macro {report.macro}",
304
+ f"project {report.project_id}{' (created)' if report.project_created else ''}",
305
+ ]
306
+ if report.shapes_cleared:
307
+ lines.append(f"cleared {report.shapes_cleared}")
308
+ lines.append(f"shapes {report.shape_count}")
309
+ lines.append(f"took {report.duration_ms} ms")
310
+ if report.result is not None:
311
+ lines.append(f"result {json.dumps(report.result, default=str)}")
312
+ return "\n".join(lines)
313
+
314
+
315
+ def _explain(error: BaseException) -> str:
316
+ """A failure a macro author can act on, rather than a stack trace through urllib."""
317
+ if isinstance(error, SketchForgeTransportError):
318
+ return f"{error}\nIs the server running, and is SKETCHFORGE_URL pointing at it?"
319
+ if isinstance(error, SketchForgeApiError):
320
+ if error.looks_disabled:
321
+ return "The server has no SKETCHFORGE_API_TOKEN set, so its REST API is switched off."
322
+ if error.needs_storage:
323
+ return "The server has no SKETCHFORGE_PROJECTS_DIR set, so it has nowhere to store projects."
324
+ return str(error)
325
+
326
+
327
+ def main(argv: Optional[Sequence[str]] = None) -> int:
328
+ parser = argparse.ArgumentParser(
329
+ prog="python -m sketchforge3d.macro",
330
+ description="Run a SketchForge macro file against the server named by the environment.",
331
+ epilog=(
332
+ "Environment: SKETCHFORGE_API_TOKEN (required), SKETCHFORGE_URL "
333
+ "(default http://127.0.0.1:3000), SKETCHFORGE_PROJECT_ID (for a target macro)."
334
+ ),
335
+ )
336
+ parser.add_argument("file", help="the macro file to run")
337
+ parser.add_argument("--project", dest="project_id", help="the project for a target macro")
338
+ parser.add_argument("--json", action="store_true", help="print the run report as JSON")
339
+ parsed, extra = parser.parse_known_args(list(argv) if argv is not None else sys.argv[1:])
340
+
341
+ try:
342
+ overrides = parse_parameters(extra)
343
+ macro = load_macro_file(parsed.file)
344
+ client = client_from_environment()
345
+ report = run_macro(
346
+ client,
347
+ macro,
348
+ params=overrides,
349
+ target_project_id=parsed.project_id or target_project_from_environment(),
350
+ log=lambda message: print(message, file=sys.stderr),
351
+ )
352
+ except SketchForgeError as error:
353
+ print(_explain(error), file=sys.stderr)
354
+ return 1
355
+
356
+ print(json.dumps(report.as_dict(), indent=2, default=str) if parsed.json else describe_report(report))
357
+ return 0