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.
- autoassembler/.fernignore +7 -0
- autoassembler/__init__.py +537 -0
- autoassembler/_custom/__init__.py +56 -0
- autoassembler/_custom/assemblies.py +226 -0
- autoassembler/_custom/assemblies_api.py +188 -0
- autoassembler/_custom/credentials.py +248 -0
- autoassembler/_custom/http.py +42 -0
- autoassembler/_custom/operations.py +258 -0
- autoassembler/_custom/planning.py +598 -0
- autoassembler/_custom/planning_api.py +225 -0
- autoassembler/_default_clients.py +32 -0
- autoassembler/_version.py +8 -0
- autoassembler/alpha/__init__.py +63 -0
- autoassembler/alpha/client.py +82 -0
- autoassembler/alpha/jobs/__init__.py +39 -0
- autoassembler/alpha/jobs/client.py +338 -0
- autoassembler/alpha/jobs/raw_client.py +641 -0
- autoassembler/alpha/jobs/types/__init__.py +42 -0
- autoassembler/alpha/jobs/types/cancel_job_jobs_response.py +12 -0
- autoassembler/alpha/jobs/types/list_jobs_jobs_response.py +30 -0
- autoassembler/alpha/jobs/types/list_jobs_jobs_response_pagination.py +22 -0
- autoassembler/alpha/jobs/types/retry_job_jobs_response.py +16 -0
- autoassembler/alpha/planning/__init__.py +49 -0
- autoassembler/alpha/planning/client.py +201 -0
- autoassembler/alpha/planning/raw_client.py +268 -0
- autoassembler/alpha/planning/types/__init__.py +47 -0
- autoassembler/alpha/planning/types/apply_ops_planning_request_namespace.py +5 -0
- autoassembler/alpha/planning/types/apply_ops_planning_request_ops_item.py +13 -0
- autoassembler/alpha/planning/types/apply_ops_planning_request_ops_item_op.py +58 -0
- autoassembler/alpha/planning/types/apply_ops_planning_response.py +13 -0
- autoassembler/alpha/raw_client.py +13 -0
- autoassembler/assemblies/__init__.py +73 -0
- autoassembler/assemblies/client.py +1177 -0
- autoassembler/assemblies/raw_client.py +1557 -0
- autoassembler/assemblies/types/__init__.py +71 -0
- autoassembler/assemblies/types/delete_assembly_response.py +12 -0
- autoassembler/assemblies/types/download_assembly_cad_request_namespace.py +5 -0
- autoassembler/assemblies/types/get_assembly_info_request_namespace.py +5 -0
- autoassembler/assemblies/types/get_status_request_namespace.py +5 -0
- autoassembler/assemblies/types/get_status_response.py +16 -0
- autoassembler/assemblies/types/get_status_response_progress.py +13 -0
- autoassembler/assemblies/types/get_status_response_status.py +7 -0
- autoassembler/assemblies/types/get_structure_request_namespace.py +5 -0
- autoassembler/assemblies/types/list_assembly_versions_request_namespace.py +5 -0
- autoassembler/assemblies/types/list_connections_request_namespace.py +5 -0
- autoassembler/assemblies/types/process_request_namespace.py +5 -0
- autoassembler/assemblies/types/update_assembly_info_request_namespace.py +5 -0
- autoassembler/auth/__init__.py +34 -0
- autoassembler/auth/client.py +282 -0
- autoassembler/auth/raw_client.py +413 -0
- autoassembler/auth/types/__init__.py +36 -0
- autoassembler/auth/types/create_access_token_request_grant_type.py +7 -0
- autoassembler/base_client.py +328 -0
- autoassembler/client.py +262 -0
- autoassembler/core/__init__.py +127 -0
- autoassembler/core/api_error.py +23 -0
- autoassembler/core/client_wrapper.py +147 -0
- autoassembler/core/datetime_utils.py +70 -0
- autoassembler/core/file.py +67 -0
- autoassembler/core/force_multipart.py +18 -0
- autoassembler/core/http_client.py +965 -0
- autoassembler/core/http_response.py +63 -0
- autoassembler/core/http_sse/__init__.py +42 -0
- autoassembler/core/http_sse/_api.py +455 -0
- autoassembler/core/http_sse/_decoders.py +74 -0
- autoassembler/core/http_sse/_exceptions.py +7 -0
- autoassembler/core/http_sse/_models.py +17 -0
- autoassembler/core/jsonable_encoder.py +133 -0
- autoassembler/core/logging.py +107 -0
- autoassembler/core/parse_error.py +36 -0
- autoassembler/core/pydantic_utilities.py +486 -0
- autoassembler/core/query_encoder.py +58 -0
- autoassembler/core/remove_none_from_dict.py +11 -0
- autoassembler/core/request_options.py +40 -0
- autoassembler/core/serialization.py +347 -0
- autoassembler/environment.py +7 -0
- autoassembler/errors/__init__.py +56 -0
- autoassembler/errors/bad_request_error.py +10 -0
- autoassembler/errors/conflict_error.py +10 -0
- autoassembler/errors/forbidden_error.py +11 -0
- autoassembler/errors/internal_server_error.py +11 -0
- autoassembler/errors/not_found_error.py +11 -0
- autoassembler/errors/unauthorized_error.py +10 -0
- autoassembler/errors/unprocessable_entity_error.py +11 -0
- autoassembler/operation.py +196 -0
- autoassembler/operations/__init__.py +4 -0
- autoassembler/operations/client.py +209 -0
- autoassembler/operations/raw_client.py +343 -0
- autoassembler/planning/__init__.py +70 -0
- autoassembler/planning/client.py +1174 -0
- autoassembler/planning/raw_client.py +1349 -0
- autoassembler/planning/types/__init__.py +68 -0
- autoassembler/planning/types/adapt_request_namespace.py +5 -0
- autoassembler/planning/types/create_mpp_request_body.py +8 -0
- autoassembler/planning/types/create_mpp_request_namespace.py +5 -0
- autoassembler/planning/types/delete_mpp_request_namespace.py +5 -0
- autoassembler/planning/types/delete_mpp_response.py +12 -0
- autoassembler/planning/types/evaluate_dfa_request_namespace.py +5 -0
- autoassembler/planning/types/get_mpp_request_namespace.py +5 -0
- autoassembler/planning/types/get_mpp_response.py +27 -0
- autoassembler/planning/types/list_mpps_request_namespace.py +5 -0
- autoassembler/planning/types/plan_request_namespace.py +5 -0
- autoassembler/planning/types/validate_request_namespace.py +5 -0
- autoassembler/py.typed +0 -0
- autoassembler/sessions/__init__.py +4 -0
- autoassembler/sessions/client.py +102 -0
- autoassembler/sessions/raw_client.py +126 -0
- autoassembler/types/__init__.py +344 -0
- autoassembler/types/access_token_response.py +14 -0
- autoassembler/types/active_session.py +25 -0
- autoassembler/types/active_session_namespace.py +5 -0
- autoassembler/types/adapt_mpp_accepted.py +26 -0
- autoassembler/types/assembly.py +30 -0
- autoassembler/types/assembly_connection.py +21 -0
- autoassembler/types/assembly_connection_relationship.py +5 -0
- autoassembler/types/assembly_connections_response.py +13 -0
- autoassembler/types/assembly_diff_metadata.py +35 -0
- autoassembler/types/assembly_diff_response.py +34 -0
- autoassembler/types/assembly_info.py +36 -0
- autoassembler/types/assembly_json_response.py +15 -0
- autoassembler/types/assembly_process_metadata.py +32 -0
- autoassembler/types/assembly_process_response.py +18 -0
- autoassembler/types/assembly_search_hit.py +31 -0
- autoassembler/types/assembly_version_summary.py +27 -0
- autoassembler/types/conflict_error_body.py +19 -0
- autoassembler/types/conflict_error_body_error.py +5 -0
- autoassembler/types/create_mpp_request.py +13 -0
- autoassembler/types/device_authorization_response.py +17 -0
- autoassembler/types/diff_accepted.py +42 -0
- autoassembler/types/diff_context_node.py +20 -0
- autoassembler/types/diff_details.py +73 -0
- autoassembler/types/diff_error_response.py +38 -0
- autoassembler/types/diff_flags.py +74 -0
- autoassembler/types/diff_ml_semantics.py +55 -0
- autoassembler/types/diff_node.py +26 -0
- autoassembler/types/diff_node_pair_with_parts.py +90 -0
- autoassembler/types/diff_node_with_parent.py +15 -0
- autoassembler/types/diff_reference_target_nodes.py +25 -0
- autoassembler/types/diff_report.py +24 -0
- autoassembler/types/diff_rigid_alignment.py +30 -0
- autoassembler/types/diff_summary.py +121 -0
- autoassembler/types/diff_summary_assembly_labels.py +44 -0
- autoassembler/types/diff_summary_geometric_diffs.py +24 -0
- autoassembler/types/diff_summary_persistent_edges_congruence.py +24 -0
- autoassembler/types/diff_summary_persistent_edges_congruence_pose.py +24 -0
- autoassembler/types/diff_summary_persistent_nodes_congruence.py +24 -0
- autoassembler/types/diff_summary_persistent_nodes_congruence_pose.py +24 -0
- autoassembler/types/diff_summary_total_parts.py +24 -0
- autoassembler/types/diff_swap.py +65 -0
- autoassembler/types/diff_swap_confidence.py +5 -0
- autoassembler/types/diff_swap_group.py +92 -0
- autoassembler/types/diff_validation_issue.py +38 -0
- autoassembler/types/diff_validation_issue_severity.py +5 -0
- autoassembler/types/diff_validation_result.py +35 -0
- autoassembler/types/diff_warning.py +79 -0
- autoassembler/types/diff_warning_reason.py +18 -0
- autoassembler/types/diff_warning_severity.py +5 -0
- autoassembler/types/error_response.py +30 -0
- autoassembler/types/filter.py +19 -0
- autoassembler/types/filter_field.py +7 -0
- autoassembler/types/filter_operator.py +5 -0
- autoassembler/types/job.py +123 -0
- autoassembler/types/job_progress.py +24 -0
- autoassembler/types/job_status.py +5 -0
- autoassembler/types/list_operations_response.py +25 -0
- autoassembler/types/manufacturing_process_plan.py +57 -0
- autoassembler/types/mpp_adapt_metadata.py +48 -0
- autoassembler/types/mpp_adapt_response.py +22 -0
- autoassembler/types/mpp_evaluate_dfa_metadata.py +47 -0
- autoassembler/types/mpp_evaluate_dfa_response.py +22 -0
- autoassembler/types/mpp_plan_metadata.py +47 -0
- autoassembler/types/mpp_plan_response.py +22 -0
- autoassembler/types/mpp_summary.py +20 -0
- autoassembler/types/mpp_validate_metadata.py +47 -0
- autoassembler/types/mpp_validate_response.py +22 -0
- autoassembler/types/o_auth_error_response.py +14 -0
- autoassembler/types/o_auth_error_response_error.py +18 -0
- autoassembler/types/operation.py +27 -0
- autoassembler/types/operation_error.py +21 -0
- autoassembler/types/operation_metadata.py +148 -0
- autoassembler/types/operation_response.py +94 -0
- autoassembler/types/process_assembly_accepted.py +14 -0
- autoassembler/types/process_assembly_response.py +55 -0
- autoassembler/types/process_assembly_skipped.py +14 -0
- autoassembler/types/search_assemblies_response.py +14 -0
- autoassembler/types/seq_plan_accepted.py +24 -0
- autoassembler/types/sever.py +33 -0
- autoassembler/types/sever_kind.py +5 -0
- autoassembler/types/sever_source.py +5 -0
- autoassembler/types/sort.py +8 -0
- autoassembler/types/upload_response.py +19 -0
- autoassembler/types/user_info.py +19 -0
- autoassembler/types/user_info_response.py +23 -0
- autoassembler/types/user_info_response_providers.py +14 -0
- autoassembler-0.3.1.dist-info/METADATA +209 -0
- autoassembler-0.3.1.dist-info/RECORD +198 -0
- autoassembler-0.3.1.dist-info/WHEEL +4 -0
- 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
|