sketchforge3d 1.2.1__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.
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: sketchforge3d
3
+ Version: 1.2.1
4
+ Summary: Typed client and macro runner for the SketchForge REST API.
5
+ Author: SketchForge contributors
6
+ License-Expression: AGPL-3.0-only
7
+ Project-URL: Homepage, https://sketchforge3d.com
8
+ Project-URL: Repository, https://github.com/constructorfleet/SketchForge-3D
9
+ Keywords: sketchforge,cad,macro,client
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+
18
+ # sketchforge3d
19
+
20
+ A Python client for the [SketchForge REST API](../../docs/REST_API.md), and a runner for **macros**: short parameterised scripts that build a part.
21
+
22
+ ```bash
23
+ pip install sketchforge3d
24
+ ```
25
+
26
+ The install name and the import name are the same, and there are no runtime dependencies: the client speaks HTTP through `urllib` from the standard library, so it installs anywhere Python does and adds nothing to a macro author's lockfile.
27
+
28
+ The API is headless -- it reads and writes projects in server-side storage with no browser open -- so a macro is a build script for a design, the way a Makefile is for a binary.
29
+
30
+ ## Connecting
31
+
32
+ ```python
33
+ from sketchforge3d import SketchForgeClient, client_from_environment
34
+
35
+ client = SketchForgeClient(base_url="http://localhost:3000", token="...")
36
+ client = client_from_environment() # SKETCHFORGE_API_TOKEN, SKETCHFORGE_URL
37
+
38
+ health = client.check()
39
+ if not health["ok"]:
40
+ raise SystemExit(health["reason"])
41
+ ```
42
+
43
+ Prefer the environment: the token is a password for every project on the server and does not belong in a script or in shell history. The server needs both `SKETCHFORGE_API_TOKEN` and `SKETCHFORGE_PROJECTS_DIR` set, or the API answers 404 and 503 respectively; `check()` says which.
44
+
45
+ ## The client
46
+
47
+ ```python
48
+ SketchForgeClient(base_url, token, timeout=60.0, retry_on_busy=4, headers=None, opener=None)
49
+
50
+ client.api_root -> str
51
+ client.check() -> dict
52
+ client.project(project_id) -> ProjectHandle
53
+
54
+ client.projects.list() -> list[ProjectSummary]
55
+ client.projects.get(project_id) -> ProjectDetail
56
+ client.projects.create(name) -> ProjectSummary
57
+ client.projects.delete(project_id) -> bool
58
+ client.projects.find_by_name(name) -> ProjectSummary | None
59
+
60
+ client.shapes.list(project_id) -> list[ShapeSummary]
61
+ client.shapes.create(project_id, shape) -> ShapeSummary
62
+ client.shapes.create_many(project_id, shapes) -> ShapeWriteResult
63
+ client.shapes.update(project_id, shape_id, changes) -> ShapeSummary
64
+ client.shapes.delete(project_id, shape_id) -> bool
65
+ client.shapes.delete_all(project_id) -> int
66
+
67
+ client.edges.list(project_id, shape_id) -> list[EdgeSummary]
68
+ client.edges.filletable_indexes(project_id, shape_id) -> list[int]
69
+
70
+ client.treatments.apply(project_id, shape_id, kind, amount, angle=None, edges=None) -> TreatmentResult
71
+ client.treatments.fillet(project_id, shape_id, radius, edges=None) -> TreatmentResult
72
+ client.treatments.chamfer(project_id, shape_id, distance, angle=None, edges=None) -> TreatmentResult
73
+ ```
74
+
75
+ A `ProjectHandle` is the same surface with the project id bound, which is what a macro works through:
76
+
77
+ ```python
78
+ project.get() | delete() | list_shapes() | add(shape) | add_many(shapes) | update(id, changes)
79
+ project.remove(id) | clear() | edges(id) | filletable_edges(id)
80
+ project.fillet(id, radius, edges=None) | chamfer(id, distance, angle=None, edges=None)
81
+ ```
82
+
83
+ Requests are plain dicts, because the server takes a bag of parameters; responses are frozen dataclasses with snake_case fields, so `shape.rotation_x` is checked and a typo is an error rather than a `None`. A field this version does not know about is kept in `shape.extra` rather than dropped.
84
+
85
+ ```python
86
+ project.add_many([
87
+ {"kind": "box", "name": "Plate", "width": 120, "depth": 40, "height": 6},
88
+ {"kind": "cylinder", "width": 8, "height": 12, "sides": 6, "x": -40, "elevation": -1, "hole": True},
89
+ {"kind": "thread", "size": "M8", "length": 10, "hole": True, "x": 40},
90
+ {"kind": "revolve", "profile": [[8, 0], [10, 0], [10, 2], [8, 2]], "pitch": 6, "turns": 6},
91
+ ])
92
+ ```
93
+
94
+ `create_many` is one write of the project package per request and splits lists longer than the server's 200-shape limit -- which also means a longer list is no longer atomic.
95
+
96
+ Failures are `SketchForgeApiError` (`status`, `detail`, plus `looks_disabled`, `needs_storage`, `is_project_busy`) or `SketchForgeTransportError`, both subclasses of `SketchForgeError`. A write that lost the project's lock is retried; a *locked shape*, which answers 409 for an unrelated reason, is not.
97
+
98
+ `sketchforge3d.layout` has the arithmetic a parameterised part repeats: `row`, `grid`, `ring`, `centered_offsets`, `angle_of`, `mm`.
99
+
100
+ ## Macros
101
+
102
+ A macro is a file that defines `MACRO` and builds something when it is handed a connected project:
103
+
104
+ ```python
105
+ # macros/bolt_plate.py
106
+ from sketchforge3d import MacroContext, define_macro
107
+
108
+
109
+ def build(context: MacroContext) -> dict:
110
+ params, layout = context.params, context.layout
111
+ context.log(f"{params['holes']} {params['size']} holes")
112
+ context.project.add_many(
113
+ [{"kind": "box", "name": "Plate", "width": 200, "depth": 40, "height": params["thickness"]}]
114
+ + [
115
+ {
116
+ "kind": "thread",
117
+ "name": f"hole {index}",
118
+ "size": params["size"],
119
+ "length": params["thickness"] + 2,
120
+ "hole": True,
121
+ "x": at.x,
122
+ "z": at.z,
123
+ "elevation": -1,
124
+ }
125
+ for index, at in enumerate(layout.row(int(params["holes"]), float(params["spacing"])), start=1)
126
+ ]
127
+ )
128
+ return {"holes": params["holes"]}
129
+
130
+
131
+ MACRO = define_macro(
132
+ name="Bolt plate",
133
+ project=("replace", "Bolt plate"),
134
+ params={"holes": 5, "spacing": 25.0, "size": "M8", "thickness": 8.0},
135
+ run=build,
136
+ )
137
+ ```
138
+
139
+ ```bash
140
+ SKETCHFORGE_API_TOKEN=... python -m sketchforge3d.macro macros/bolt_plate.py --holes=7 --spacing=30
141
+ ```
142
+
143
+ `python -m sketchforge3d`, and the `sketchforge-macro` console script, run the same thing. The context carries everything the macro needs -- `client`, `project`, `params`, `layout`, `log` -- so a macro file imports only `define_macro`.
144
+
145
+ ### Project modes, and why there is no default
146
+
147
+ The second run is what makes a macro useful or dangerous. A script that appends to a project it found doubles the part every time, and nobody notices until they open the project. So the mode is declared and the runner, not the macro body, acts on it:
148
+
149
+ | `project` | What the runner does first | Use it for |
150
+ | --- | --- | --- |
151
+ | `"fresh"` | Creates a new project, name stamped with the time | Something you generate several of to compare |
152
+ | `("replace", name)` | Finds that project and **empties** it, or creates it | A part you iterate on; re-running is idempotent |
153
+ | `("reuse", name)` | Finds or creates it, and leaves the contents alone | Adding a feature to whatever is there |
154
+ | `"target"` | Requires `SKETCHFORGE_PROJECT_ID` or `--project`, touches nothing else | Operating on a project someone else named |
155
+
156
+ A module with a bare `run(context)` function instead of a `MACRO` runs in a `fresh` project, because that is the only mode that is safe to assume.
157
+
158
+ ### Parameters
159
+
160
+ `params` is both the defaults and the schema. An override is coerced to the type of its default -- an `int` default refuses `4.5`, a `float` default takes it -- and a name the macro does not declare is refused, so `--hoels=7` is an error rather than a silently ignored typo. Parameters are resolved before the project is created, so a bad one costs nothing.
161
+
162
+ ## Examples
163
+
164
+ `examples/` holds the three worked macros, which double as the integration tests:
165
+
166
+ | Macro | Builds |
167
+ | --- | --- |
168
+ | `bolt_plate.py` | A plate sized around a computed row of tapped holes |
169
+ | `bolt_circle.py` | A flange with N tapped holes on a ring of radius R |
170
+ | `filleted_box.py` | A box from three numbers, with every roundable edge filleted on the server |
171
+
172
+ Tapped holes are `{"kind": "thread", "hole": True}`: the form is cut wider by a clearance and the shape is marked as a hole, so grouping it with the plate in the editor taps the plate. The API has no grouping endpoint yet, so the macros stop at marked holes.
173
+
174
+ ## Development
175
+
176
+ Tests are `unittest` from the standard library, so there is nothing to install:
177
+
178
+ ```bash
179
+ PYTHONPATH=src:tests python3 -m unittest discover -s tests -t tests # unit
180
+ PYTHONPATH=src python3 -m unittest discover -s tests/integration -t tests/integration # needs a live server
181
+ ```
182
+
183
+ From the repository root those are `npm run client:test:py` and `npm run client:test:py:integration`. The integration tests need a real server:
184
+
185
+ ```bash
186
+ SKETCHFORGE_PROJECTS_DIR=$(mktemp -d) SKETCHFORGE_API_TOKEN=test-token \
187
+ npx next dev apps/web -p 3100 &
188
+ export SKETCHFORGE_API_TOKEN=test-token SKETCHFORGE_URL=http://127.0.0.1:3100
189
+ npm run client:test:py:integration
190
+ ```
191
+
192
+ ## Publishing
193
+
194
+ See [docs/PUBLISHING_CLIENTS.md](../../docs/PUBLISHING_CLIENTS.md) for the trusted-publisher settings PyPI needs before the release workflow can publish this package.
@@ -0,0 +1,177 @@
1
+ # sketchforge3d
2
+
3
+ A Python client for the [SketchForge REST API](../../docs/REST_API.md), and a runner for **macros**: short parameterised scripts that build a part.
4
+
5
+ ```bash
6
+ pip install sketchforge3d
7
+ ```
8
+
9
+ The install name and the import name are the same, and there are no runtime dependencies: the client speaks HTTP through `urllib` from the standard library, so it installs anywhere Python does and adds nothing to a macro author's lockfile.
10
+
11
+ The API is headless -- it reads and writes projects in server-side storage with no browser open -- so a macro is a build script for a design, the way a Makefile is for a binary.
12
+
13
+ ## Connecting
14
+
15
+ ```python
16
+ from sketchforge3d import SketchForgeClient, client_from_environment
17
+
18
+ client = SketchForgeClient(base_url="http://localhost:3000", token="...")
19
+ client = client_from_environment() # SKETCHFORGE_API_TOKEN, SKETCHFORGE_URL
20
+
21
+ health = client.check()
22
+ if not health["ok"]:
23
+ raise SystemExit(health["reason"])
24
+ ```
25
+
26
+ Prefer the environment: the token is a password for every project on the server and does not belong in a script or in shell history. The server needs both `SKETCHFORGE_API_TOKEN` and `SKETCHFORGE_PROJECTS_DIR` set, or the API answers 404 and 503 respectively; `check()` says which.
27
+
28
+ ## The client
29
+
30
+ ```python
31
+ SketchForgeClient(base_url, token, timeout=60.0, retry_on_busy=4, headers=None, opener=None)
32
+
33
+ client.api_root -> str
34
+ client.check() -> dict
35
+ client.project(project_id) -> ProjectHandle
36
+
37
+ client.projects.list() -> list[ProjectSummary]
38
+ client.projects.get(project_id) -> ProjectDetail
39
+ client.projects.create(name) -> ProjectSummary
40
+ client.projects.delete(project_id) -> bool
41
+ client.projects.find_by_name(name) -> ProjectSummary | None
42
+
43
+ client.shapes.list(project_id) -> list[ShapeSummary]
44
+ client.shapes.create(project_id, shape) -> ShapeSummary
45
+ client.shapes.create_many(project_id, shapes) -> ShapeWriteResult
46
+ client.shapes.update(project_id, shape_id, changes) -> ShapeSummary
47
+ client.shapes.delete(project_id, shape_id) -> bool
48
+ client.shapes.delete_all(project_id) -> int
49
+
50
+ client.edges.list(project_id, shape_id) -> list[EdgeSummary]
51
+ client.edges.filletable_indexes(project_id, shape_id) -> list[int]
52
+
53
+ client.treatments.apply(project_id, shape_id, kind, amount, angle=None, edges=None) -> TreatmentResult
54
+ client.treatments.fillet(project_id, shape_id, radius, edges=None) -> TreatmentResult
55
+ client.treatments.chamfer(project_id, shape_id, distance, angle=None, edges=None) -> TreatmentResult
56
+ ```
57
+
58
+ A `ProjectHandle` is the same surface with the project id bound, which is what a macro works through:
59
+
60
+ ```python
61
+ project.get() | delete() | list_shapes() | add(shape) | add_many(shapes) | update(id, changes)
62
+ project.remove(id) | clear() | edges(id) | filletable_edges(id)
63
+ project.fillet(id, radius, edges=None) | chamfer(id, distance, angle=None, edges=None)
64
+ ```
65
+
66
+ Requests are plain dicts, because the server takes a bag of parameters; responses are frozen dataclasses with snake_case fields, so `shape.rotation_x` is checked and a typo is an error rather than a `None`. A field this version does not know about is kept in `shape.extra` rather than dropped.
67
+
68
+ ```python
69
+ project.add_many([
70
+ {"kind": "box", "name": "Plate", "width": 120, "depth": 40, "height": 6},
71
+ {"kind": "cylinder", "width": 8, "height": 12, "sides": 6, "x": -40, "elevation": -1, "hole": True},
72
+ {"kind": "thread", "size": "M8", "length": 10, "hole": True, "x": 40},
73
+ {"kind": "revolve", "profile": [[8, 0], [10, 0], [10, 2], [8, 2]], "pitch": 6, "turns": 6},
74
+ ])
75
+ ```
76
+
77
+ `create_many` is one write of the project package per request and splits lists longer than the server's 200-shape limit -- which also means a longer list is no longer atomic.
78
+
79
+ Failures are `SketchForgeApiError` (`status`, `detail`, plus `looks_disabled`, `needs_storage`, `is_project_busy`) or `SketchForgeTransportError`, both subclasses of `SketchForgeError`. A write that lost the project's lock is retried; a *locked shape*, which answers 409 for an unrelated reason, is not.
80
+
81
+ `sketchforge3d.layout` has the arithmetic a parameterised part repeats: `row`, `grid`, `ring`, `centered_offsets`, `angle_of`, `mm`.
82
+
83
+ ## Macros
84
+
85
+ A macro is a file that defines `MACRO` and builds something when it is handed a connected project:
86
+
87
+ ```python
88
+ # macros/bolt_plate.py
89
+ from sketchforge3d import MacroContext, define_macro
90
+
91
+
92
+ def build(context: MacroContext) -> dict:
93
+ params, layout = context.params, context.layout
94
+ context.log(f"{params['holes']} {params['size']} holes")
95
+ context.project.add_many(
96
+ [{"kind": "box", "name": "Plate", "width": 200, "depth": 40, "height": params["thickness"]}]
97
+ + [
98
+ {
99
+ "kind": "thread",
100
+ "name": f"hole {index}",
101
+ "size": params["size"],
102
+ "length": params["thickness"] + 2,
103
+ "hole": True,
104
+ "x": at.x,
105
+ "z": at.z,
106
+ "elevation": -1,
107
+ }
108
+ for index, at in enumerate(layout.row(int(params["holes"]), float(params["spacing"])), start=1)
109
+ ]
110
+ )
111
+ return {"holes": params["holes"]}
112
+
113
+
114
+ MACRO = define_macro(
115
+ name="Bolt plate",
116
+ project=("replace", "Bolt plate"),
117
+ params={"holes": 5, "spacing": 25.0, "size": "M8", "thickness": 8.0},
118
+ run=build,
119
+ )
120
+ ```
121
+
122
+ ```bash
123
+ SKETCHFORGE_API_TOKEN=... python -m sketchforge3d.macro macros/bolt_plate.py --holes=7 --spacing=30
124
+ ```
125
+
126
+ `python -m sketchforge3d`, and the `sketchforge-macro` console script, run the same thing. The context carries everything the macro needs -- `client`, `project`, `params`, `layout`, `log` -- so a macro file imports only `define_macro`.
127
+
128
+ ### Project modes, and why there is no default
129
+
130
+ The second run is what makes a macro useful or dangerous. A script that appends to a project it found doubles the part every time, and nobody notices until they open the project. So the mode is declared and the runner, not the macro body, acts on it:
131
+
132
+ | `project` | What the runner does first | Use it for |
133
+ | --- | --- | --- |
134
+ | `"fresh"` | Creates a new project, name stamped with the time | Something you generate several of to compare |
135
+ | `("replace", name)` | Finds that project and **empties** it, or creates it | A part you iterate on; re-running is idempotent |
136
+ | `("reuse", name)` | Finds or creates it, and leaves the contents alone | Adding a feature to whatever is there |
137
+ | `"target"` | Requires `SKETCHFORGE_PROJECT_ID` or `--project`, touches nothing else | Operating on a project someone else named |
138
+
139
+ A module with a bare `run(context)` function instead of a `MACRO` runs in a `fresh` project, because that is the only mode that is safe to assume.
140
+
141
+ ### Parameters
142
+
143
+ `params` is both the defaults and the schema. An override is coerced to the type of its default -- an `int` default refuses `4.5`, a `float` default takes it -- and a name the macro does not declare is refused, so `--hoels=7` is an error rather than a silently ignored typo. Parameters are resolved before the project is created, so a bad one costs nothing.
144
+
145
+ ## Examples
146
+
147
+ `examples/` holds the three worked macros, which double as the integration tests:
148
+
149
+ | Macro | Builds |
150
+ | --- | --- |
151
+ | `bolt_plate.py` | A plate sized around a computed row of tapped holes |
152
+ | `bolt_circle.py` | A flange with N tapped holes on a ring of radius R |
153
+ | `filleted_box.py` | A box from three numbers, with every roundable edge filleted on the server |
154
+
155
+ Tapped holes are `{"kind": "thread", "hole": True}`: the form is cut wider by a clearance and the shape is marked as a hole, so grouping it with the plate in the editor taps the plate. The API has no grouping endpoint yet, so the macros stop at marked holes.
156
+
157
+ ## Development
158
+
159
+ Tests are `unittest` from the standard library, so there is nothing to install:
160
+
161
+ ```bash
162
+ PYTHONPATH=src:tests python3 -m unittest discover -s tests -t tests # unit
163
+ PYTHONPATH=src python3 -m unittest discover -s tests/integration -t tests/integration # needs a live server
164
+ ```
165
+
166
+ From the repository root those are `npm run client:test:py` and `npm run client:test:py:integration`. The integration tests need a real server:
167
+
168
+ ```bash
169
+ SKETCHFORGE_PROJECTS_DIR=$(mktemp -d) SKETCHFORGE_API_TOKEN=test-token \
170
+ npx next dev apps/web -p 3100 &
171
+ export SKETCHFORGE_API_TOKEN=test-token SKETCHFORGE_URL=http://127.0.0.1:3100
172
+ npm run client:test:py:integration
173
+ ```
174
+
175
+ ## Publishing
176
+
177
+ See [docs/PUBLISHING_CLIENTS.md](../../docs/PUBLISHING_CLIENTS.md) for the trusted-publisher settings PyPI needs before the release workflow can publish this package.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sketchforge3d"
7
+ version = "1.2.1"
8
+ description = "Typed client and macro runner for the SketchForge REST API."
9
+ readme = "README.md"
10
+ license = "AGPL-3.0-only"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "SketchForge contributors" }]
13
+ keywords = ["sketchforge", "cad", "macro", "client"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Multimedia :: Graphics :: 3D Modeling",
19
+ "Typing :: Typed",
20
+ ]
21
+ # Deliberately empty: the client uses urllib from the standard library, so it
22
+ # installs anywhere Python does and adds nothing to a macro author's lockfile.
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://sketchforge3d.com"
27
+ Repository = "https://github.com/constructorfleet/SketchForge-3D"
28
+
29
+ [project.scripts]
30
+ sketchforge-macro = "sketchforge3d.macro:main"
31
+
32
+ [tool.setuptools]
33
+ package-dir = { "" = "src" }
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ sketchforge3d = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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())
@@ -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