autoassembler 0.3.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.
Files changed (198) hide show
  1. autoassembler/.fernignore +7 -0
  2. autoassembler/__init__.py +537 -0
  3. autoassembler/_custom/__init__.py +56 -0
  4. autoassembler/_custom/assemblies.py +226 -0
  5. autoassembler/_custom/assemblies_api.py +188 -0
  6. autoassembler/_custom/credentials.py +248 -0
  7. autoassembler/_custom/http.py +42 -0
  8. autoassembler/_custom/operations.py +258 -0
  9. autoassembler/_custom/planning.py +598 -0
  10. autoassembler/_custom/planning_api.py +225 -0
  11. autoassembler/_default_clients.py +32 -0
  12. autoassembler/_version.py +8 -0
  13. autoassembler/alpha/__init__.py +63 -0
  14. autoassembler/alpha/client.py +82 -0
  15. autoassembler/alpha/jobs/__init__.py +39 -0
  16. autoassembler/alpha/jobs/client.py +338 -0
  17. autoassembler/alpha/jobs/raw_client.py +641 -0
  18. autoassembler/alpha/jobs/types/__init__.py +42 -0
  19. autoassembler/alpha/jobs/types/cancel_job_jobs_response.py +12 -0
  20. autoassembler/alpha/jobs/types/list_jobs_jobs_response.py +30 -0
  21. autoassembler/alpha/jobs/types/list_jobs_jobs_response_pagination.py +22 -0
  22. autoassembler/alpha/jobs/types/retry_job_jobs_response.py +16 -0
  23. autoassembler/alpha/planning/__init__.py +49 -0
  24. autoassembler/alpha/planning/client.py +201 -0
  25. autoassembler/alpha/planning/raw_client.py +268 -0
  26. autoassembler/alpha/planning/types/__init__.py +47 -0
  27. autoassembler/alpha/planning/types/apply_ops_planning_request_namespace.py +5 -0
  28. autoassembler/alpha/planning/types/apply_ops_planning_request_ops_item.py +13 -0
  29. autoassembler/alpha/planning/types/apply_ops_planning_request_ops_item_op.py +58 -0
  30. autoassembler/alpha/planning/types/apply_ops_planning_response.py +13 -0
  31. autoassembler/alpha/raw_client.py +13 -0
  32. autoassembler/assemblies/__init__.py +73 -0
  33. autoassembler/assemblies/client.py +1177 -0
  34. autoassembler/assemblies/raw_client.py +1557 -0
  35. autoassembler/assemblies/types/__init__.py +71 -0
  36. autoassembler/assemblies/types/delete_assembly_response.py +12 -0
  37. autoassembler/assemblies/types/download_assembly_cad_request_namespace.py +5 -0
  38. autoassembler/assemblies/types/get_assembly_info_request_namespace.py +5 -0
  39. autoassembler/assemblies/types/get_status_request_namespace.py +5 -0
  40. autoassembler/assemblies/types/get_status_response.py +16 -0
  41. autoassembler/assemblies/types/get_status_response_progress.py +13 -0
  42. autoassembler/assemblies/types/get_status_response_status.py +7 -0
  43. autoassembler/assemblies/types/get_structure_request_namespace.py +5 -0
  44. autoassembler/assemblies/types/list_assembly_versions_request_namespace.py +5 -0
  45. autoassembler/assemblies/types/list_connections_request_namespace.py +5 -0
  46. autoassembler/assemblies/types/process_request_namespace.py +5 -0
  47. autoassembler/assemblies/types/update_assembly_info_request_namespace.py +5 -0
  48. autoassembler/auth/__init__.py +34 -0
  49. autoassembler/auth/client.py +282 -0
  50. autoassembler/auth/raw_client.py +413 -0
  51. autoassembler/auth/types/__init__.py +36 -0
  52. autoassembler/auth/types/create_access_token_request_grant_type.py +7 -0
  53. autoassembler/base_client.py +328 -0
  54. autoassembler/client.py +262 -0
  55. autoassembler/core/__init__.py +127 -0
  56. autoassembler/core/api_error.py +23 -0
  57. autoassembler/core/client_wrapper.py +147 -0
  58. autoassembler/core/datetime_utils.py +70 -0
  59. autoassembler/core/file.py +67 -0
  60. autoassembler/core/force_multipart.py +18 -0
  61. autoassembler/core/http_client.py +965 -0
  62. autoassembler/core/http_response.py +63 -0
  63. autoassembler/core/http_sse/__init__.py +42 -0
  64. autoassembler/core/http_sse/_api.py +455 -0
  65. autoassembler/core/http_sse/_decoders.py +74 -0
  66. autoassembler/core/http_sse/_exceptions.py +7 -0
  67. autoassembler/core/http_sse/_models.py +17 -0
  68. autoassembler/core/jsonable_encoder.py +133 -0
  69. autoassembler/core/logging.py +107 -0
  70. autoassembler/core/parse_error.py +36 -0
  71. autoassembler/core/pydantic_utilities.py +486 -0
  72. autoassembler/core/query_encoder.py +58 -0
  73. autoassembler/core/remove_none_from_dict.py +11 -0
  74. autoassembler/core/request_options.py +40 -0
  75. autoassembler/core/serialization.py +347 -0
  76. autoassembler/environment.py +7 -0
  77. autoassembler/errors/__init__.py +56 -0
  78. autoassembler/errors/bad_request_error.py +10 -0
  79. autoassembler/errors/conflict_error.py +10 -0
  80. autoassembler/errors/forbidden_error.py +11 -0
  81. autoassembler/errors/internal_server_error.py +11 -0
  82. autoassembler/errors/not_found_error.py +11 -0
  83. autoassembler/errors/unauthorized_error.py +10 -0
  84. autoassembler/errors/unprocessable_entity_error.py +11 -0
  85. autoassembler/operation.py +196 -0
  86. autoassembler/operations/__init__.py +4 -0
  87. autoassembler/operations/client.py +209 -0
  88. autoassembler/operations/raw_client.py +343 -0
  89. autoassembler/planning/__init__.py +70 -0
  90. autoassembler/planning/client.py +1174 -0
  91. autoassembler/planning/raw_client.py +1349 -0
  92. autoassembler/planning/types/__init__.py +68 -0
  93. autoassembler/planning/types/adapt_request_namespace.py +5 -0
  94. autoassembler/planning/types/create_mpp_request_body.py +8 -0
  95. autoassembler/planning/types/create_mpp_request_namespace.py +5 -0
  96. autoassembler/planning/types/delete_mpp_request_namespace.py +5 -0
  97. autoassembler/planning/types/delete_mpp_response.py +12 -0
  98. autoassembler/planning/types/evaluate_dfa_request_namespace.py +5 -0
  99. autoassembler/planning/types/get_mpp_request_namespace.py +5 -0
  100. autoassembler/planning/types/get_mpp_response.py +27 -0
  101. autoassembler/planning/types/list_mpps_request_namespace.py +5 -0
  102. autoassembler/planning/types/plan_request_namespace.py +5 -0
  103. autoassembler/planning/types/validate_request_namespace.py +5 -0
  104. autoassembler/py.typed +0 -0
  105. autoassembler/sessions/__init__.py +4 -0
  106. autoassembler/sessions/client.py +102 -0
  107. autoassembler/sessions/raw_client.py +126 -0
  108. autoassembler/types/__init__.py +344 -0
  109. autoassembler/types/access_token_response.py +14 -0
  110. autoassembler/types/active_session.py +25 -0
  111. autoassembler/types/active_session_namespace.py +5 -0
  112. autoassembler/types/adapt_mpp_accepted.py +26 -0
  113. autoassembler/types/assembly.py +30 -0
  114. autoassembler/types/assembly_connection.py +21 -0
  115. autoassembler/types/assembly_connection_relationship.py +5 -0
  116. autoassembler/types/assembly_connections_response.py +13 -0
  117. autoassembler/types/assembly_diff_metadata.py +35 -0
  118. autoassembler/types/assembly_diff_response.py +34 -0
  119. autoassembler/types/assembly_info.py +36 -0
  120. autoassembler/types/assembly_json_response.py +15 -0
  121. autoassembler/types/assembly_process_metadata.py +32 -0
  122. autoassembler/types/assembly_process_response.py +18 -0
  123. autoassembler/types/assembly_search_hit.py +31 -0
  124. autoassembler/types/assembly_version_summary.py +27 -0
  125. autoassembler/types/conflict_error_body.py +19 -0
  126. autoassembler/types/conflict_error_body_error.py +5 -0
  127. autoassembler/types/create_mpp_request.py +13 -0
  128. autoassembler/types/device_authorization_response.py +17 -0
  129. autoassembler/types/diff_accepted.py +42 -0
  130. autoassembler/types/diff_context_node.py +20 -0
  131. autoassembler/types/diff_details.py +73 -0
  132. autoassembler/types/diff_error_response.py +38 -0
  133. autoassembler/types/diff_flags.py +74 -0
  134. autoassembler/types/diff_ml_semantics.py +55 -0
  135. autoassembler/types/diff_node.py +26 -0
  136. autoassembler/types/diff_node_pair_with_parts.py +90 -0
  137. autoassembler/types/diff_node_with_parent.py +15 -0
  138. autoassembler/types/diff_reference_target_nodes.py +25 -0
  139. autoassembler/types/diff_report.py +24 -0
  140. autoassembler/types/diff_rigid_alignment.py +30 -0
  141. autoassembler/types/diff_summary.py +121 -0
  142. autoassembler/types/diff_summary_assembly_labels.py +44 -0
  143. autoassembler/types/diff_summary_geometric_diffs.py +24 -0
  144. autoassembler/types/diff_summary_persistent_edges_congruence.py +24 -0
  145. autoassembler/types/diff_summary_persistent_edges_congruence_pose.py +24 -0
  146. autoassembler/types/diff_summary_persistent_nodes_congruence.py +24 -0
  147. autoassembler/types/diff_summary_persistent_nodes_congruence_pose.py +24 -0
  148. autoassembler/types/diff_summary_total_parts.py +24 -0
  149. autoassembler/types/diff_swap.py +65 -0
  150. autoassembler/types/diff_swap_confidence.py +5 -0
  151. autoassembler/types/diff_swap_group.py +92 -0
  152. autoassembler/types/diff_validation_issue.py +38 -0
  153. autoassembler/types/diff_validation_issue_severity.py +5 -0
  154. autoassembler/types/diff_validation_result.py +35 -0
  155. autoassembler/types/diff_warning.py +79 -0
  156. autoassembler/types/diff_warning_reason.py +18 -0
  157. autoassembler/types/diff_warning_severity.py +5 -0
  158. autoassembler/types/error_response.py +30 -0
  159. autoassembler/types/filter.py +19 -0
  160. autoassembler/types/filter_field.py +7 -0
  161. autoassembler/types/filter_operator.py +5 -0
  162. autoassembler/types/job.py +123 -0
  163. autoassembler/types/job_progress.py +24 -0
  164. autoassembler/types/job_status.py +5 -0
  165. autoassembler/types/list_operations_response.py +25 -0
  166. autoassembler/types/manufacturing_process_plan.py +57 -0
  167. autoassembler/types/mpp_adapt_metadata.py +48 -0
  168. autoassembler/types/mpp_adapt_response.py +22 -0
  169. autoassembler/types/mpp_evaluate_dfa_metadata.py +47 -0
  170. autoassembler/types/mpp_evaluate_dfa_response.py +22 -0
  171. autoassembler/types/mpp_plan_metadata.py +47 -0
  172. autoassembler/types/mpp_plan_response.py +22 -0
  173. autoassembler/types/mpp_summary.py +20 -0
  174. autoassembler/types/mpp_validate_metadata.py +47 -0
  175. autoassembler/types/mpp_validate_response.py +22 -0
  176. autoassembler/types/o_auth_error_response.py +14 -0
  177. autoassembler/types/o_auth_error_response_error.py +18 -0
  178. autoassembler/types/operation.py +27 -0
  179. autoassembler/types/operation_error.py +21 -0
  180. autoassembler/types/operation_metadata.py +148 -0
  181. autoassembler/types/operation_response.py +94 -0
  182. autoassembler/types/process_assembly_accepted.py +14 -0
  183. autoassembler/types/process_assembly_response.py +55 -0
  184. autoassembler/types/process_assembly_skipped.py +14 -0
  185. autoassembler/types/search_assemblies_response.py +14 -0
  186. autoassembler/types/seq_plan_accepted.py +24 -0
  187. autoassembler/types/sever.py +33 -0
  188. autoassembler/types/sever_kind.py +5 -0
  189. autoassembler/types/sever_source.py +5 -0
  190. autoassembler/types/sort.py +8 -0
  191. autoassembler/types/upload_response.py +19 -0
  192. autoassembler/types/user_info.py +19 -0
  193. autoassembler/types/user_info_response.py +23 -0
  194. autoassembler/types/user_info_response_providers.py +14 -0
  195. autoassembler-0.3.1.dist-info/METADATA +209 -0
  196. autoassembler-0.3.1.dist-info/RECORD +198 -0
  197. autoassembler-0.3.1.dist-info/WHEEL +4 -0
  198. autoassembler-0.3.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,226 @@
1
+ """The `Assembly` handle and the curated `client.assemblies` namespace.
2
+
3
+ Hand-written, protected via `.fernignore`. Built on the generated client, which
4
+ stays reachable as ``client.api.assemblies`` for anything not covered here.
5
+
6
+ Vocabulary: a CAD file becomes an `Assembly`, which is ``ready`` to be diffed or
7
+ planned once the service has processed it; ``diff()`` compares two of them.
8
+
9
+ ``upload()`` and ``diff()`` return the server's `Operation`: ``upload()`` calls ``process``
10
+ right after the upload, because the upload endpoint only stores the file, and resolves to
11
+ the `Assembly` once processing is done; ``diff()`` resolves to the `DiffReport` once it is
12
+ written.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import typing
19
+ from pathlib import Path
20
+
21
+ from ..assemblies.types.get_status_response import GetStatusResponse
22
+ from ..core.pydantic_utilities import parse_obj_as
23
+ from ..operation import Operation
24
+ from ..types.assembly_connections_response import AssemblyConnectionsResponse
25
+ from ..types.assembly_json_response import AssemblyJsonResponse
26
+ from ..types.diff_report import DiffReport
27
+ from ..types.operation import Operation as OperationResource
28
+ from ..types.operation_response import OperationResponse_AssemblyDiff
29
+ from ..types.process_assembly_response import ProcessAssemblyResponse_Ok
30
+
31
+ if typing.TYPE_CHECKING:
32
+ from ..client import AutoAssembler
33
+ from .planning import ProcessPlan
34
+
35
+ Namespace = typing.Literal["user", "shared", "public"]
36
+ """Where an assembly lives in the URL space. Uploads land in ``user``."""
37
+
38
+ AssemblyStatus = typing.Literal["processing", "ready", "failed"]
39
+ """The three states a caller needs: being worked on, usable, or not going to be."""
40
+
41
+ # Content types the upload endpoint accepts, by extension.
42
+ _CONTENT_TYPES = {
43
+ ".step": "application/step",
44
+ ".stp": "application/step",
45
+ ".pvz": "application/octet-stream",
46
+ }
47
+
48
+
49
+ class Assembly:
50
+ """A CAD file the service knows: the handle every assembly verb hangs off.
51
+
52
+ Carries its own addressing (``namespace`` and ``id``), so you never pass either
53
+ around. Obtained from ``client.assemblies.upload(...)`` or
54
+ ``client.assemblies.get(id)``; never constructed directly.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ client: AutoAssembler,
60
+ *,
61
+ id: str,
62
+ namespace: Namespace = "user",
63
+ name: str | None = None,
64
+ viewer_url: str | None = None,
65
+ ):
66
+ self._client = client
67
+ self.id = id
68
+ self.namespace: Namespace = namespace
69
+ self.name = name
70
+ self._viewer_url = viewer_url
71
+
72
+ def __repr__(self) -> str:
73
+ return f"Assembly({self.namespace}/{self.id}, name={self.name!r})"
74
+
75
+ def __eq__(self, other: object) -> bool:
76
+ return isinstance(other, Assembly) and (other.namespace, other.id) == (
77
+ self.namespace,
78
+ self.id,
79
+ )
80
+
81
+ def __hash__(self) -> int:
82
+ return hash((self.namespace, self.id))
83
+
84
+ @property
85
+ def viewer_url(self) -> str:
86
+ """The assembly in the UI."""
87
+ if self._viewer_url:
88
+ return self._viewer_url
89
+ return f"{self._client.base_url}/assemblies/{self.namespace}/{self.id}"
90
+
91
+ @property
92
+ def status(self) -> AssemblyStatus:
93
+ """``processing``, ``ready`` or ``failed`` (one request).
94
+
95
+ ``failed`` includes an assembly whose processing is not running and never
96
+ finished.
97
+ """
98
+ return _status_of(self._client.api.assemblies.get_status(self.namespace, self.id))
99
+
100
+ def structure(self) -> AssemblyJsonResponse:
101
+ """The assembly's structure (its eBOM), as processed."""
102
+ return self._client.api.assemblies.get_structure(self.namespace, self.id)
103
+
104
+ def connections(self) -> AssemblyConnectionsResponse:
105
+ """Part-to-part connections (the liaison graph), as processed."""
106
+ return self._client.api.assemblies.list_connections(self.namespace, self.id)
107
+
108
+ def plans(self) -> list[ProcessPlan]:
109
+ """The process plans that exist for this assembly."""
110
+ return self._client.planning.list_mpps(self)
111
+
112
+ def delete(self) -> None:
113
+ """Delete the assembly (only your own uploads can be deleted)."""
114
+ self._client.api.assemblies.delete_assembly(self.id)
115
+
116
+
117
+ class Assemblies:
118
+ """``client.assemblies``: CAD files in, `Assembly` handles and reports out."""
119
+
120
+ def __init__(self, client: AutoAssembler):
121
+ self._client = client
122
+
123
+ def upload(
124
+ self,
125
+ file: str | os.PathLike[str] | typing.BinaryIO,
126
+ *,
127
+ filename: str | None = None,
128
+ attributes: dict[str, list[str]] | None = None,
129
+ ) -> Operation[Assembly]:
130
+ """Upload a CAD file (``.step``, ``.stp``, ``.pvz``) and start processing it.
131
+
132
+ Returns once uploaded, with the processing operation; ``result()`` returns the
133
+ `Assembly` once it is ``ready``, or raises `OperationFailed` with the reason (e.g.
134
+ the file is not an assembly). ``op.metadata.assembly_id`` names it from the start.
135
+ Start several uploads before waiting on any to have their processing overlap on
136
+ the server.
137
+
138
+ ``file`` is a path or an open binary file (then ``filename`` is required unless
139
+ the object has a ``name``). ``attributes`` are parsing patterns, e.g.
140
+ ``{"consolidated": ["*bolt*"]}``, applied to this processing run.
141
+ """
142
+ if isinstance(file, str | os.PathLike):
143
+ path = Path(file)
144
+ with path.open("rb") as handle:
145
+ response = self._post_upload(handle, filename or path.name)
146
+ else:
147
+ name = filename or getattr(file, "name", None)
148
+ if not name:
149
+ raise ValueError("upload(): pass filename= when uploading from a file object")
150
+ response = self._post_upload(file, os.path.basename(str(name)))
151
+
152
+ uploaded = response.assembly
153
+ if uploaded is None or uploaded.id is None:
154
+ raise RuntimeError("upload: the server returned no assembly id")
155
+ assembly = Assembly(
156
+ self._client,
157
+ id=uploaded.id,
158
+ namespace="user",
159
+ name=uploaded.name,
160
+ viewer_url=response.viewer_url,
161
+ )
162
+
163
+ # The upload endpoint only stores the file; processing is a separate call.
164
+ kwargs: dict[str, typing.Any] = {}
165
+ if attributes is not None:
166
+ kwargs["attributes"] = attributes
167
+ processing = self._client.api.assemblies.process(assembly.namespace, assembly.id, **kwargs)
168
+ if not isinstance(processing, ProcessAssemblyResponse_Ok):
169
+ raise RuntimeError(
170
+ f"upload: the server queued no processing for {assembly.id} ({processing.status})"
171
+ )
172
+ # The `ok` body is the operation, next to `status`.
173
+ operation = parse_obj_as(OperationResource, processing.dict(by_alias=True))
174
+ return self._client.operations.handle(operation, resolve=lambda _: assembly)
175
+
176
+ def _post_upload(self, handle: typing.BinaryIO, filename: str):
177
+ content_type = _CONTENT_TYPES.get(Path(filename).suffix.lower(), "application/octet-stream")
178
+ return self._client.api.assemblies.upload(file=(filename, handle, content_type))
179
+
180
+ def get(self, id: str, *, namespace: Namespace = "user") -> Assembly:
181
+ """A handle on an existing assembly (one request, to fetch its record)."""
182
+ info = self._client.api.assemblies.get_assembly_info(namespace, id)
183
+ return Assembly(self._client, id=id, namespace=namespace, name=info.name)
184
+
185
+ def diff(
186
+ self,
187
+ reference: Assembly | str,
188
+ target: Assembly | str,
189
+ *,
190
+ context_hops: int = 1,
191
+ ) -> Operation[DiffReport]:
192
+ """Compare a baseline assembly with a later one.
193
+
194
+ Both assemblies must be ``ready``. Returns the diff's operation; ``result()``
195
+ returns the typed report: ``summary`` holds the counts, ``details`` the parts
196
+ behind them. A comparison the server already made comes back done.
197
+ """
198
+ reference_id = reference.id if isinstance(reference, Assembly) else reference
199
+ target_id = target.id if isinstance(target, Assembly) else target
200
+ kwargs: dict[str, typing.Any] = {}
201
+ if context_hops != 1:
202
+ kwargs["context_n_hops"] = context_hops
203
+ api = self._client.api.assemblies
204
+ computing = api.diff(reference_id=reference_id, target_id=target_id, async_=True, **kwargs)
205
+
206
+ def resolve(finished: OperationResource) -> DiffReport:
207
+ if not isinstance(finished.response, OperationResponse_AssemblyDiff):
208
+ raise TypeError(f"{finished.name} is not a diff: {finished.metadata.type}")
209
+ return api.get_diff_report(finished.response.result_url)
210
+
211
+ return self._client.operations.handle(computing.operation, resolve=resolve)
212
+
213
+
214
+ def _status_of(status: GetStatusResponse) -> AssemblyStatus:
215
+ """Fold a ``status.json`` reading into the three states.
216
+
217
+ An ``error`` is ``failed``, even next to ``ready`` (the server marks a failed run
218
+ ready so the UI can show the error); ``queued`` and ``processing`` are
219
+ ``processing``; ``unprocessed`` means no run is queued and none finished, so it is
220
+ ``failed`` too, as is ``not_found``.
221
+ """
222
+ if status.status in ("queued", "processing"):
223
+ return "processing"
224
+ if status.error or status.status != "ready":
225
+ return "failed"
226
+ return "ready"
@@ -0,0 +1,188 @@
1
+ """``client.api.assemblies``: the generated assemblies client, with an operation for async diffs.
2
+
3
+ Hand-written, protected via `.fernignore`. `AssembliesApi` subclasses the generated
4
+ `AssembliesClient` (keeping every assembly call). The generated ``diff`` reads any 2xx as a
5
+ `DiffReport`, so with ``async_=True`` it cannot read the 202, which is the operation tracking
6
+ the diff: the override returns an `Operation` for it instead, and `get_diff_report` fetches
7
+ the report the finished operation's ``resultUrl`` serves (a server path, not in the spec).
8
+
9
+ TODO: the blocking diff (no ``async``) is deprecated; once its deprecation period
10
+ ends, the endpoint always answers 202 and drops the ``async`` flag. Then the spec has a single
11
+ 2xx, so the generated ``diff`` reads `DiffAccepted` itself: drop the overloads, `_diff_body`,
12
+ `_parsed` and the raw request, and keep ``diff`` as a thin wrapper of ``super().diff()`` in an
13
+ `Operation`, as `PlanningApi` does. Once the spec also documents the report route,
14
+ `get_diff_report` becomes generated and goes too.
15
+ """
16
+
17
+ import typing
18
+
19
+ from ..assemblies.client import OMIT, AssembliesClient, AsyncAssembliesClient
20
+ from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper
21
+ from ..core.request_options import RequestOptions
22
+ from ..operation import AsyncOperation, Operation
23
+ from ..types.diff_accepted import DiffAccepted
24
+ from ..types.diff_report import DiffReport
25
+ from ..types.operation_response import OperationResponse_AssemblyDiff
26
+ from .http import parsed
27
+ from .operations import AsyncOperations, Operations, async_response_of, response_of
28
+
29
+ _DIFF_PATH = "api/v4/assemblies/diff"
30
+
31
+
32
+ def _diff_body(
33
+ reference_id: str, target_id: str, context_n_hops: int | None
34
+ ) -> dict[str, typing.Any]:
35
+ return {
36
+ "referenceId": reference_id,
37
+ "targetId": target_id,
38
+ "contextNHops": context_n_hops,
39
+ "async": True,
40
+ }
41
+
42
+
43
+ def _report_path(result_url: str) -> str:
44
+ # The server answers with a root-relative path; the HTTP client joins it to the base URL.
45
+ return result_url.lstrip("/")
46
+
47
+
48
+ class AssembliesApi(AssembliesClient):
49
+ """Generated assemblies client + an operation for async diffs."""
50
+
51
+ def __init__(self, *, client_wrapper: SyncClientWrapper, operations: Operations):
52
+ super().__init__(client_wrapper=client_wrapper)
53
+ self._client_wrapper = client_wrapper
54
+ self._operations = operations
55
+
56
+ @typing.overload
57
+ def diff(
58
+ self,
59
+ *,
60
+ reference_id: str,
61
+ target_id: str,
62
+ context_n_hops: int | None = ...,
63
+ async_: typing.Literal[False] | None = ...,
64
+ request_options: RequestOptions | None = ...,
65
+ ) -> DiffReport: ...
66
+ @typing.overload
67
+ def diff(
68
+ self,
69
+ *,
70
+ reference_id: str,
71
+ target_id: str,
72
+ context_n_hops: int | None = ...,
73
+ async_: typing.Literal[True],
74
+ request_options: RequestOptions | None = ...,
75
+ ) -> Operation[OperationResponse_AssemblyDiff]: ...
76
+ def diff( # pyright: ignore[reportIncompatibleMethodOverride]
77
+ self,
78
+ *,
79
+ reference_id: str,
80
+ target_id: str,
81
+ context_n_hops: int | None = OMIT,
82
+ async_: bool | None = OMIT,
83
+ request_options: RequestOptions | None = None,
84
+ ) -> DiffReport | Operation[OperationResponse_AssemblyDiff]:
85
+ """Compare a reference assembly with a target one.
86
+
87
+ Without ``async_`` the call blocks and returns the `DiffReport`. With ``async_=True``
88
+ it returns the operation computing it: its result holds the ``resultUrl`` that
89
+ `get_diff_report` reads. A diff already computed comes back done.
90
+ """
91
+ if async_ is not True: # unset (OMIT), None or False
92
+ return super().diff(
93
+ reference_id=reference_id,
94
+ target_id=target_id,
95
+ context_n_hops=context_n_hops,
96
+ async_=async_,
97
+ request_options=request_options,
98
+ )
99
+ response = self._client_wrapper.httpx_client.request(
100
+ _DIFF_PATH,
101
+ method="POST",
102
+ json=_diff_body(reference_id, target_id, context_n_hops),
103
+ headers={"content-type": "application/json"},
104
+ request_options=request_options,
105
+ omit=OMIT,
106
+ )
107
+ accepted = parsed(response, DiffAccepted)
108
+ return self._operations.handle(
109
+ accepted, resolve=response_of(OperationResponse_AssemblyDiff)
110
+ )
111
+
112
+ def get_diff_report(
113
+ self, result_url: str, *, request_options: RequestOptions | None = None
114
+ ) -> DiffReport:
115
+ """The report a finished async diff wrote, from its response's ``result_url``."""
116
+ response = self._client_wrapper.httpx_client.request(
117
+ _report_path(result_url), method="GET", request_options=request_options
118
+ )
119
+ return parsed(response, DiffReport)
120
+
121
+
122
+ class AsyncAssembliesApi(AsyncAssembliesClient):
123
+ """Async generated assemblies client + an operation for async diffs (see `AssembliesApi`)."""
124
+
125
+ def __init__(self, *, client_wrapper: AsyncClientWrapper, operations: AsyncOperations):
126
+ super().__init__(client_wrapper=client_wrapper)
127
+ self._client_wrapper = client_wrapper
128
+ self._operations = operations
129
+
130
+ @typing.overload
131
+ async def diff(
132
+ self,
133
+ *,
134
+ reference_id: str,
135
+ target_id: str,
136
+ context_n_hops: int | None = ...,
137
+ async_: typing.Literal[False] | None = ...,
138
+ request_options: RequestOptions | None = ...,
139
+ ) -> DiffReport: ...
140
+ @typing.overload
141
+ async def diff(
142
+ self,
143
+ *,
144
+ reference_id: str,
145
+ target_id: str,
146
+ context_n_hops: int | None = ...,
147
+ async_: typing.Literal[True],
148
+ request_options: RequestOptions | None = ...,
149
+ ) -> AsyncOperation[OperationResponse_AssemblyDiff]: ...
150
+ async def diff( # pyright: ignore[reportIncompatibleMethodOverride]
151
+ self,
152
+ *,
153
+ reference_id: str,
154
+ target_id: str,
155
+ context_n_hops: int | None = OMIT,
156
+ async_: bool | None = OMIT,
157
+ request_options: RequestOptions | None = None,
158
+ ) -> DiffReport | AsyncOperation[OperationResponse_AssemblyDiff]:
159
+ """Compare a reference assembly with a target one; see `AssembliesApi.diff`."""
160
+ if async_ is not True: # unset (OMIT), None or False
161
+ return await super().diff(
162
+ reference_id=reference_id,
163
+ target_id=target_id,
164
+ context_n_hops=context_n_hops,
165
+ async_=async_,
166
+ request_options=request_options,
167
+ )
168
+ response = await self._client_wrapper.httpx_client.request(
169
+ _DIFF_PATH,
170
+ method="POST",
171
+ json=_diff_body(reference_id, target_id, context_n_hops),
172
+ headers={"content-type": "application/json"},
173
+ request_options=request_options,
174
+ omit=OMIT,
175
+ )
176
+ accepted = parsed(response, DiffAccepted)
177
+ return await self._operations.handle(
178
+ accepted, resolve=async_response_of(OperationResponse_AssemblyDiff)
179
+ )
180
+
181
+ async def get_diff_report(
182
+ self, result_url: str, *, request_options: RequestOptions | None = None
183
+ ) -> DiffReport:
184
+ """The report a finished async diff wrote; see `AssembliesApi.get_diff_report`."""
185
+ response = await self._client_wrapper.httpx_client.request(
186
+ _report_path(result_url), method="GET", request_options=request_options
187
+ )
188
+ return parsed(response, DiffReport)
@@ -0,0 +1,248 @@
1
+ """Device login and the credential store.
2
+
3
+ Hand-written, protected via `.fernignore`. Built on the generated ``auth`` client
4
+ (``create_device_authorization`` / ``create_access_token``, RFC 8628).
5
+
6
+ Three ways to hold a token, in the order ``AutoAssembler()`` looks for one:
7
+
8
+ 1. ``AutoAssembler(token=...)``: explicit, what CI and scripts do (an API key's secret
9
+ is a bearer token).
10
+ 2. The ``AA_TOKEN`` environment variable.
11
+ 3. The credential store written by `login`: ``$XDG_CONFIG_HOME/autoassembler/credentials.json``
12
+ (``~/.config/autoassembler/credentials.json`` by default), one entry per server URL,
13
+ file mode 0600.
14
+
15
+ `login` runs the OAuth device authorization grant: it asks the server for a code, prints
16
+ the URL and the code for the person to approve in a browser, polls the token endpoint
17
+ until the approval lands, and stores the token. Tokens last 30 days and have no refresh,
18
+ so an expired store means logging in again; `AutoAssembler()` says so.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ import typing
29
+ from pathlib import Path
30
+
31
+ from .._version import __version__
32
+ from ..base_client import BaseAutoAssembler
33
+ from ..core.api_error import ApiError
34
+ from ..core.request_options import RequestOptions
35
+ from ..environment import AutoAssemblerEnvironment
36
+
37
+ ENV_TOKEN = "AA_TOKEN" # noqa: S105 (the variable name, not a secret)
38
+ """Environment variable `AutoAssembler()` reads a bearer token from."""
39
+
40
+ DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
41
+
42
+ # A stored token this close to expiry is treated as expired, so a request never fails mid-flight.
43
+ _EXPIRY_SKEW_SECONDS = 60.0
44
+
45
+
46
+ class CredentialsError(RuntimeError):
47
+ """No usable token: none given, none in the environment, none stored (or it expired)."""
48
+
49
+
50
+ class DeviceAuthorizationError(RuntimeError):
51
+ """The device authorization grant ended without a token.
52
+
53
+ ``code`` is the server's OAuth error code (RFC 6749 §5.2 / RFC 8628 §3.5), e.g.
54
+ ``expired_token``, ``access_denied``, ``invalid_grant``; ``None`` for a timeout here.
55
+ """
56
+
57
+ def __init__(self, message: str, *, code: str | None = None):
58
+ super().__init__(message)
59
+ self.code = code
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Resolution
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def resolve_token(base_url: str) -> str:
68
+ """The token `AutoAssembler()` uses for ``base_url`` when none is passed.
69
+
70
+ ``AA_TOKEN`` first, then the credential store. Raises `CredentialsError` naming
71
+ `login` when neither has one.
72
+ """
73
+ from_env = os.environ.get(ENV_TOKEN)
74
+ if from_env:
75
+ return from_env
76
+ stored = stored_token(base_url)
77
+ if stored is not None:
78
+ return stored
79
+ raise CredentialsError(
80
+ f"No credentials for {_normalize(base_url)}: pass token=..., set {ENV_TOKEN}, "
81
+ "or run autoassembler.login()."
82
+ )
83
+
84
+
85
+ def stored_token(base_url: str) -> str | None:
86
+ """The stored, unexpired token for ``base_url``, or ``None``."""
87
+ entry = _read_store().get("credentials", {}).get(_normalize(base_url))
88
+ if not isinstance(entry, dict):
89
+ return None
90
+ token = entry.get("access_token")
91
+ expires_at = entry.get("expires_at")
92
+ if not isinstance(token, str) or not isinstance(expires_at, int | float):
93
+ return None
94
+ if time.time() >= expires_at - _EXPIRY_SKEW_SECONDS:
95
+ return None
96
+ return token
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Login / logout
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ def login(
105
+ base_url: str | None = None,
106
+ *,
107
+ force: bool = False,
108
+ output: typing.TextIO | None = None,
109
+ timeout: float | None = None,
110
+ ) -> str:
111
+ """Log in to ``base_url`` (production by default) with the device authorization grant.
112
+
113
+ Prints a URL and a code to ``output`` (stderr by default); open the URL, enter the
114
+ code, and this returns once the server has issued a token, which is also stored for
115
+ `AutoAssembler()` to pick up. A stored, unexpired token is reused unless ``force``.
116
+ ``timeout`` caps the wait in seconds (the code's own lifetime by default).
117
+
118
+ Raises `DeviceAuthorizationError` if the grant ends without a token.
119
+ """
120
+ url = _normalize(base_url or AutoAssemblerEnvironment.PRODUCTION.value)
121
+ out = output or sys.stderr
122
+ if not force:
123
+ existing = stored_token(url)
124
+ if existing is not None:
125
+ print(f"Already logged in to {url} (login(force=True) logs in again).", file=out)
126
+ return existing
127
+
128
+ # The auth endpoints take no token; the generated client is used directly.
129
+ api = BaseAutoAssembler(
130
+ base_url=url, token=None, headers={"User-Agent": f"autoassembler-python/{__version__}"}
131
+ )
132
+ grant = api.auth.create_device_authorization()
133
+
134
+ print(f"To log in to {url}, open:", file=out)
135
+ print(f" {grant.verification_uri_complete}", file=out)
136
+ print(f"and confirm the code {grant.user_code}", file=out)
137
+ out.flush()
138
+
139
+ interval = float(grant.interval or 5)
140
+ deadline = time.monotonic() + (timeout if timeout is not None else float(grant.expires_in))
141
+ while True:
142
+ if time.monotonic() >= deadline:
143
+ raise DeviceAuthorizationError(
144
+ f"the code {grant.user_code} was not confirmed in time; run login() again"
145
+ )
146
+ time.sleep(interval)
147
+ try:
148
+ token = api.auth.create_access_token(
149
+ grant_type=DEVICE_GRANT,
150
+ device_code=grant.device_code,
151
+ # The transport would retry a 429 by itself; the RFC says to slow down instead.
152
+ request_options=RequestOptions(max_retries=0),
153
+ )
154
+ except ApiError as exc:
155
+ code = _oauth_error_code(exc)
156
+ if code == "authorization_pending":
157
+ continue
158
+ if code == "slow_down" or exc.status_code == 429:
159
+ interval += 5
160
+ continue
161
+ raise DeviceAuthorizationError(
162
+ f"device authorization failed: {code or f'HTTP {exc.status_code}'}", code=code
163
+ ) from exc
164
+ break
165
+
166
+ _save(url, token.access_token, expires_in=float(token.expires_in))
167
+ print("Logged in.", file=out)
168
+ out.flush()
169
+ return token.access_token
170
+
171
+
172
+ def logout(base_url: str | None = None) -> bool:
173
+ """Forget the stored credential for ``base_url`` (production by default).
174
+
175
+ Returns whether there was one. The token itself stays valid until it expires: the
176
+ server has no revocation for device tokens yet.
177
+ """
178
+ url = _normalize(base_url or AutoAssemblerEnvironment.PRODUCTION.value)
179
+ store = _read_store()
180
+ credentials = store.get("credentials")
181
+ if not isinstance(credentials, dict) or url not in credentials:
182
+ return False
183
+ del credentials[url]
184
+ _write_store(store)
185
+ return True
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # The store
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def credentials_file() -> Path:
194
+ """Where `login` keeps tokens: ``$XDG_CONFIG_HOME/autoassembler/credentials.json``."""
195
+ config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
196
+ return Path(config_home) / "autoassembler" / "credentials.json"
197
+
198
+
199
+ def _read_store() -> dict[str, typing.Any]:
200
+ path = credentials_file()
201
+ try:
202
+ data = json.loads(path.read_text())
203
+ except FileNotFoundError:
204
+ return {"version": 1, "credentials": {}}
205
+ except (OSError, ValueError) as exc:
206
+ raise CredentialsError(f"cannot read {path}: {exc}") from exc
207
+ if not isinstance(data, dict):
208
+ raise CredentialsError(f"cannot read {path}: not a JSON object")
209
+ data.setdefault("version", 1)
210
+ data.setdefault("credentials", {})
211
+ return data
212
+
213
+
214
+ def _write_store(data: dict[str, typing.Any]) -> None:
215
+ path = credentials_file()
216
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
217
+ # Create with private permissions before writing any token. A unique name also
218
+ # avoids following a pre-existing credentials.json.tmp symlink.
219
+ fd, name = tempfile.mkstemp(dir=path.parent, prefix=".credentials-", suffix=".tmp")
220
+ tmp = Path(name)
221
+ try:
222
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
223
+ stream.write(json.dumps(data, indent=2) + "\n")
224
+ os.replace(tmp, path)
225
+ finally:
226
+ tmp.unlink(missing_ok=True)
227
+
228
+
229
+ def _save(url: str, access_token: str, *, expires_in: float) -> None:
230
+ store = _read_store()
231
+ store["credentials"][url] = {
232
+ "access_token": access_token,
233
+ "token_type": "Bearer",
234
+ "expires_at": time.time() + expires_in,
235
+ }
236
+ _write_store(store)
237
+
238
+
239
+ def _normalize(base_url: str) -> str:
240
+ return base_url.rstrip("/")
241
+
242
+
243
+ def _oauth_error_code(exc: ApiError) -> str | None:
244
+ body = exc.body
245
+ if isinstance(body, dict):
246
+ code = body.get("error")
247
+ return code if isinstance(code, str) else None
248
+ return None