codecraftai-restclient-tailscale 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ccai/restclient/tailscale/__init__.py +21 -0
- ccai/restclient/tailscale/_codegen/__init__.py +11 -0
- ccai/restclient/tailscale/_codegen/emitter.py +288 -0
- ccai/restclient/tailscale/_codegen/import_openapi.py +174 -0
- ccai/restclient/tailscale/_codegen/typemap.py +97 -0
- ccai/restclient/tailscale/_spec/.gitkeep +0 -0
- ccai/restclient/tailscale/_spec/openapi.json +6134 -0
- ccai/restclient/tailscale/_spec/openapi.sha256 +1 -0
- ccai/restclient/tailscale/apigroups/__init__.py +11 -0
- ccai/restclient/tailscale/apigroups/aclsgroup.py +93 -0
- ccai/restclient/tailscale/apigroups/devicesgroup.py +209 -0
- ccai/restclient/tailscale/apigroups/dnsgroup.py +180 -0
- ccai/restclient/tailscale/apigroups/groupsupport.py +65 -0
- ccai/restclient/tailscale/apigroups/keysgroup.py +211 -0
- ccai/restclient/tailscale/apigroups/posturegroup.py +125 -0
- ccai/restclient/tailscale/apigroups/tailnetsgroup.py +75 -0
- ccai/restclient/tailscale/aspectstailscale.py +108 -0
- ccai/restclient/tailscale/exceptions.py +122 -0
- ccai/restclient/tailscale/model/__init__.py +69 -0
- ccai/restclient/tailscale/model/tailscaleawsexternalid.py +33 -0
- ccai/restclient/tailscale/model/tailscaleconfigurationauditlog.py +41 -0
- ccai/restclient/tailscale/model/tailscaleconnectioncounts.py +36 -0
- ccai/restclient/tailscale/model/tailscalecontact.py +34 -0
- ccai/restclient/tailscale/model/tailscaledevice.py +62 -0
- ccai/restclient/tailscale/model/tailscaledeviceinvite.py +44 -0
- ccai/restclient/tailscale/model/tailscaledevicepostureattributes.py +31 -0
- ccai/restclient/tailscale/model/tailscaledeviceroutes.py +31 -0
- ccai/restclient/tailscale/model/tailscalednsconfiguration.py +33 -0
- ccai/restclient/tailscale/model/tailscalednsconfigurationpreferences.py +31 -0
- ccai/restclient/tailscale/model/tailscalednsconfigurationresolver.py +31 -0
- ccai/restclient/tailscale/model/tailscalednspreferences.py +28 -0
- ccai/restclient/tailscale/model/tailscalednssearchpaths.py +30 -0
- ccai/restclient/tailscale/model/tailscaleerror.py +28 -0
- ccai/restclient/tailscale/model/tailscalekey.py +50 -0
- ccai/restclient/tailscale/model/tailscalekeycapabilities.py +32 -0
- ccai/restclient/tailscale/model/tailscalelogstreamendpointconfiguration.py +50 -0
- ccai/restclient/tailscale/model/tailscalelogstreamendpointpublishingstatus.py +41 -0
- ccai/restclient/tailscale/model/tailscalenetworkflowlog.py +37 -0
- ccai/restclient/tailscale/model/tailscalepostureintegration.py +39 -0
- ccai/restclient/tailscale/model/tailscaleservicehostinfo.py +34 -0
- ccai/restclient/tailscale/model/tailscalesplitdns.py +29 -0
- ccai/restclient/tailscale/model/tailscaletailnetsettings.py +42 -0
- ccai/restclient/tailscale/model/tailscaleuser.py +44 -0
- ccai/restclient/tailscale/model/tailscaleuserinvite.py +39 -0
- ccai/restclient/tailscale/model/tailscalevipserviceapproval.py +33 -0
- ccai/restclient/tailscale/model/tailscalevipserviceinfo.py +36 -0
- ccai/restclient/tailscale/model/tailscalevipserviceinfoput.py +27 -0
- ccai/restclient/tailscale/model/tailscalewebhook.py +38 -0
- ccai/restclient/tailscale/oauthbearer.py +174 -0
- ccai/restclient/tailscale/py.typed +0 -0
- ccai/restclient/tailscale/tailscaleclient.py +514 -0
- codecraftai_restclient_tailscale-1.2.0.dist-info/METADATA +99 -0
- codecraftai_restclient_tailscale-1.2.0.dist-info/RECORD +55 -0
- codecraftai_restclient_tailscale-1.2.0.dist-info/WHEEL +4 -0
- codecraftai_restclient_tailscale-1.2.0.dist-info/licenses/LICENSE.md +204 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.. module:: tailscale
|
|
3
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
4
|
+
:synopsis: Typed, aspect-driven REST client package for the Tailscale
|
|
5
|
+
public API (``api.tailscale.com/api/v2/``).
|
|
6
|
+
|
|
7
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__author__ = "Myron Walker"
|
|
11
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
12
|
+
__credits__ = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from ccai.restclient.tailscale.tailscaleclient import TailscaleClient
|
|
16
|
+
from ccai.restclient.tailscale.aspectstailscale import (
|
|
17
|
+
AspectsTailscale, DEFAULT_TAILSCALE_ASPECTS
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ["TailscaleClient", "AspectsTailscale", "DEFAULT_TAILSCALE_ASPECTS"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.. module:: _codegen
|
|
3
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
4
|
+
:synopsis: Maintainer-facing OpenAPI import and model-emitter package.
|
|
5
|
+
|
|
6
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__author__ = "Myron Walker"
|
|
10
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
11
|
+
__credits__ = []
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.. module:: emitter
|
|
3
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
4
|
+
:synopsis: Module that walks the ``components/schemas`` section of a
|
|
5
|
+
Tailscale OpenAPI document and emits one
|
|
6
|
+
:class:`SchemaObject` subclass file per schema.
|
|
7
|
+
|
|
8
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__author__ = "Myron Walker"
|
|
12
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
13
|
+
__credits__ = []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from typing import Any, Dict, List, Tuple
|
|
20
|
+
|
|
21
|
+
from ccai.restclient.tailscale._codegen.typemap import python_type_for_schema
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
GENERATED_HEADER = '''"""
|
|
25
|
+
.. module:: {module_name}
|
|
26
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
27
|
+
:synopsis: Auto-generated Tailscale OpenAPI model class.
|
|
28
|
+
|
|
29
|
+
DO NOT EDIT BY HAND. This file is generated by the codegen importer.
|
|
30
|
+
Run ``python -m ccai.restclient.tailscale._codegen.import_openapi``
|
|
31
|
+
to regenerate after the upstream OpenAPI document changes.
|
|
32
|
+
|
|
33
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__author__ = "Myron Walker"
|
|
37
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
38
|
+
__credits__ = []
|
|
39
|
+
|
|
40
|
+
'''
|
|
41
|
+
|
|
42
|
+
# The import statements a generated model module may need, in the order they are emitted. Each entry is the
|
|
43
|
+
# module name and the names that can be imported from it. An empty entry marks the break between the standard
|
|
44
|
+
# library group and the ccai group.
|
|
45
|
+
MODEL_IMPORT_CANDIDATES = [
|
|
46
|
+
("datetime", ["datetime"]),
|
|
47
|
+
("typing", ["Any", "Dict", "List", "Optional"]),
|
|
48
|
+
None,
|
|
49
|
+
("ccai.restclient.base.schemaobject", ["SchemaObject"]),
|
|
50
|
+
("ccai.restclient.base.schemafield", ["Field"]),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def class_name_for_schema(*, schema_name: str) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Compute the emitted Python class name for an OpenAPI schema name.
|
|
57
|
+
|
|
58
|
+
:param schema_name: The schema key from ``components/schemas``.
|
|
59
|
+
|
|
60
|
+
:returns: A ``Tailscale``-prefixed PascalCase class name.
|
|
61
|
+
"""
|
|
62
|
+
cleaned = schema_name.replace("_", " ").replace("-", " ")
|
|
63
|
+
parts = cleaned.split()
|
|
64
|
+
pascal_parts: List[str] = []
|
|
65
|
+
for part in parts:
|
|
66
|
+
if len(part) == 0:
|
|
67
|
+
continue
|
|
68
|
+
pascal_parts.append(part[0].upper() + part[1:])
|
|
69
|
+
|
|
70
|
+
joined = "".join(pascal_parts)
|
|
71
|
+
rtnval = "Tailscale{}".format(joined)
|
|
72
|
+
return rtnval
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def module_name_for_class(*, class_name: str) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Compute the lowercase-no-underscore module file stem for a class.
|
|
78
|
+
|
|
79
|
+
:param class_name: The emitted Python class name.
|
|
80
|
+
|
|
81
|
+
:returns: The module file stem (no ``.py`` suffix).
|
|
82
|
+
"""
|
|
83
|
+
rtnval = class_name.lower()
|
|
84
|
+
return rtnval
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _emit_field_line(*, field_name: str, field_schema: Dict[str, Any],
|
|
88
|
+
required: bool) -> str:
|
|
89
|
+
"""
|
|
90
|
+
Build a single ``Field`` assignment source line for a schema property.
|
|
91
|
+
|
|
92
|
+
:param field_name: The OpenAPI property name.
|
|
93
|
+
:param field_schema: The OpenAPI schema fragment for the property.
|
|
94
|
+
:param required: Whether the property is in the schema ``required``
|
|
95
|
+
list.
|
|
96
|
+
|
|
97
|
+
:returns: A single line of Python source.
|
|
98
|
+
"""
|
|
99
|
+
py_type = python_type_for_schema(schema=field_schema)
|
|
100
|
+
|
|
101
|
+
description = field_schema.get("description")
|
|
102
|
+
desc_arg = ""
|
|
103
|
+
if description is not None:
|
|
104
|
+
flat = " ".join(description.split())
|
|
105
|
+
safe = flat.replace('"', "'")
|
|
106
|
+
desc_arg = ', description="{}"'.format(safe)
|
|
107
|
+
|
|
108
|
+
if required is True:
|
|
109
|
+
rtnval = ' {} = Field({}, required=True{})'.format(
|
|
110
|
+
field_name, py_type, desc_arg)
|
|
111
|
+
return rtnval
|
|
112
|
+
|
|
113
|
+
rtnval = ' {} = Field(Optional[{}], required=False, default=None{})'.format(
|
|
114
|
+
field_name, py_type, desc_arg)
|
|
115
|
+
return rtnval
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _emit_import_lines(*, body_text: str) -> List[str]:
|
|
119
|
+
"""
|
|
120
|
+
Emit the import statements for a generated model module, limited to the names the module body references.
|
|
121
|
+
|
|
122
|
+
:param body_text: The generated class source.
|
|
123
|
+
|
|
124
|
+
:returns: The import lines, with a blank line between the standard library and ccai groups.
|
|
125
|
+
"""
|
|
126
|
+
referenced_names = set()
|
|
127
|
+
tree = ast.parse(body_text)
|
|
128
|
+
for node in ast.walk(tree):
|
|
129
|
+
if isinstance(node, ast.Name):
|
|
130
|
+
referenced_names.add(node.id)
|
|
131
|
+
|
|
132
|
+
groups: List[List[str]] = [[]]
|
|
133
|
+
for candidate in MODEL_IMPORT_CANDIDATES:
|
|
134
|
+
if candidate is None:
|
|
135
|
+
groups.append([])
|
|
136
|
+
continue
|
|
137
|
+
module_name, names = candidate
|
|
138
|
+
used_names = []
|
|
139
|
+
for name in names:
|
|
140
|
+
if name in referenced_names:
|
|
141
|
+
used_names.append(name)
|
|
142
|
+
if len(used_names) > 0:
|
|
143
|
+
groups[-1].append("from {} import {}".format(module_name, ", ".join(used_names)))
|
|
144
|
+
|
|
145
|
+
lines: List[str] = []
|
|
146
|
+
for group in groups:
|
|
147
|
+
if len(group) == 0:
|
|
148
|
+
continue
|
|
149
|
+
if len(lines) > 0:
|
|
150
|
+
lines.append("")
|
|
151
|
+
lines.extend(group)
|
|
152
|
+
|
|
153
|
+
return lines
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def emit_schema_module(*, schema_name: str,
|
|
157
|
+
schema: Dict[str, Any]) -> Tuple[str, str, str]:
|
|
158
|
+
"""
|
|
159
|
+
Emit the Python source for one OpenAPI schema.
|
|
160
|
+
|
|
161
|
+
:param schema_name: The schema key from ``components/schemas``.
|
|
162
|
+
:param schema: The OpenAPI schema fragment dictionary.
|
|
163
|
+
|
|
164
|
+
:returns: A tuple of ``(class_name, module_name, source_text)``.
|
|
165
|
+
"""
|
|
166
|
+
class_name = class_name_for_schema(schema_name=schema_name)
|
|
167
|
+
module_name = module_name_for_class(class_name=class_name)
|
|
168
|
+
|
|
169
|
+
# The class body is generated first so the imports can be limited to the names it references.
|
|
170
|
+
lines: List[str] = []
|
|
171
|
+
lines.append("class {}(SchemaObject):".format(class_name))
|
|
172
|
+
|
|
173
|
+
description = schema.get("description")
|
|
174
|
+
doc_summary = "Auto-generated from Tailscale OpenAPI schema '{}'.".format(
|
|
175
|
+
schema_name)
|
|
176
|
+
lines.append(' """')
|
|
177
|
+
lines.append(" {}".format(doc_summary))
|
|
178
|
+
if description is not None:
|
|
179
|
+
flat = " ".join(description.split())
|
|
180
|
+
lines.append("")
|
|
181
|
+
lines.append(" {}".format(flat))
|
|
182
|
+
lines.append(' """')
|
|
183
|
+
|
|
184
|
+
properties = schema.get("properties")
|
|
185
|
+
required_list = schema.get("required")
|
|
186
|
+
if required_list is None:
|
|
187
|
+
required_list = []
|
|
188
|
+
|
|
189
|
+
if properties is None or len(properties) == 0:
|
|
190
|
+
lines.append("")
|
|
191
|
+
lines.append(" pass")
|
|
192
|
+
else:
|
|
193
|
+
lines.append("")
|
|
194
|
+
# Stable, deterministic ordering: required first then alphabetical.
|
|
195
|
+
ordered_names = sorted(
|
|
196
|
+
properties.keys(),
|
|
197
|
+
key=lambda name: (0 if name in required_list else 1, name))
|
|
198
|
+
for prop_name in ordered_names:
|
|
199
|
+
prop_schema = properties[prop_name]
|
|
200
|
+
is_required = prop_name in required_list
|
|
201
|
+
field_line = _emit_field_line(field_name=prop_name,
|
|
202
|
+
field_schema=prop_schema,
|
|
203
|
+
required=is_required)
|
|
204
|
+
lines.append(field_line)
|
|
205
|
+
|
|
206
|
+
lines.append("")
|
|
207
|
+
body_text = os.linesep.join(lines)
|
|
208
|
+
|
|
209
|
+
header_lines: List[str] = []
|
|
210
|
+
header_lines.append(GENERATED_HEADER.format(module_name=module_name))
|
|
211
|
+
header_lines.extend(_emit_import_lines(body_text=body_text))
|
|
212
|
+
header_lines.append("")
|
|
213
|
+
header_lines.append("")
|
|
214
|
+
header_lines.append("")
|
|
215
|
+
header_lines.append("")
|
|
216
|
+
|
|
217
|
+
source_text = os.linesep.join(header_lines) + body_text
|
|
218
|
+
rtnval = (class_name, module_name, source_text)
|
|
219
|
+
return rtnval
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def emit_all_schemas(*, schemas: Dict[str, Any]) -> Dict[str, Tuple[str, str]]:
|
|
223
|
+
"""
|
|
224
|
+
Emit Python source for every object schema in a schemas section.
|
|
225
|
+
|
|
226
|
+
:param schemas: The ``components/schemas`` dictionary.
|
|
227
|
+
|
|
228
|
+
:returns: A mapping of ``module_name`` to ``(class_name, source)``.
|
|
229
|
+
"""
|
|
230
|
+
rtnval: Dict[str, Tuple[str, str]] = {}
|
|
231
|
+
|
|
232
|
+
for schema_name in sorted(schemas.keys()):
|
|
233
|
+
schema = schemas[schema_name]
|
|
234
|
+
if not isinstance(schema, dict):
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
declared_type = schema.get("type")
|
|
238
|
+
has_props = "properties" in schema
|
|
239
|
+
has_allof = "allOf" in schema
|
|
240
|
+
|
|
241
|
+
# Only object-shaped schemas become model classes. Scalar/enum
|
|
242
|
+
# aliases (string, array) are not emitted as classes.
|
|
243
|
+
if declared_type != "object" and not has_props and not has_allof:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
class_name, module_name, source_text = emit_schema_module(
|
|
247
|
+
schema_name=schema_name, schema=schema)
|
|
248
|
+
rtnval[module_name] = (class_name, source_text)
|
|
249
|
+
|
|
250
|
+
return rtnval
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def emit_model_init(*, emitted: Dict[str, Tuple[str, str]]) -> str:
|
|
254
|
+
"""
|
|
255
|
+
Emit the ``model/__init__.py`` re-export source.
|
|
256
|
+
|
|
257
|
+
:param emitted: The mapping produced by :func:`emit_all_schemas`.
|
|
258
|
+
|
|
259
|
+
:returns: The ``__init__.py`` source text.
|
|
260
|
+
"""
|
|
261
|
+
lines: List[str] = []
|
|
262
|
+
lines.append('"""')
|
|
263
|
+
lines.append(" .. module:: model")
|
|
264
|
+
lines.append(" :synopsis: Re-exports for the generated Tailscale "
|
|
265
|
+
"OpenAPI model classes.")
|
|
266
|
+
lines.append("")
|
|
267
|
+
lines.append(" DO NOT EDIT BY HAND. Regenerate via")
|
|
268
|
+
lines.append(" ``python -m ccai.restclient.tailscale._codegen."
|
|
269
|
+
"import_openapi``.")
|
|
270
|
+
lines.append('"""')
|
|
271
|
+
lines.append("")
|
|
272
|
+
|
|
273
|
+
all_names: List[str] = []
|
|
274
|
+
for module_name in sorted(emitted.keys()):
|
|
275
|
+
class_name, _ = emitted[module_name]
|
|
276
|
+
all_names.append(class_name)
|
|
277
|
+
lines.append("from ccai.restclient.tailscale.model.{} import {}".format(
|
|
278
|
+
module_name, class_name))
|
|
279
|
+
|
|
280
|
+
lines.append("")
|
|
281
|
+
lines.append("__all__ = [")
|
|
282
|
+
for class_name in sorted(all_names):
|
|
283
|
+
lines.append(' "{}",'.format(class_name))
|
|
284
|
+
lines.append("]")
|
|
285
|
+
lines.append("")
|
|
286
|
+
|
|
287
|
+
rtnval = os.linesep.join(lines)
|
|
288
|
+
return rtnval
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.. module:: import_openapi
|
|
3
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
4
|
+
:synopsis: Maintainer-facing script that fetches the Tailscale
|
|
5
|
+
OpenAPI document, validates it, and regenerates the
|
|
6
|
+
typed model classes under ``model/``.
|
|
7
|
+
|
|
8
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__author__ = "Myron Walker"
|
|
12
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
13
|
+
__credits__ = []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from typing import Any, Dict
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from ccai.errors.exceptions import ConfigurationError, SemanticError
|
|
25
|
+
|
|
26
|
+
from ccai.restclient.tailscale._codegen.emitter import (
|
|
27
|
+
emit_all_schemas, emit_model_init
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
OPENAPI_URL = "https://api.tailscale.com/api/v2?outputOpenapiSchema=true"
|
|
32
|
+
|
|
33
|
+
PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
SPEC_DIR = os.path.join(PACKAGE_ROOT, "_spec")
|
|
35
|
+
MODEL_DIR = os.path.join(PACKAGE_ROOT, "model")
|
|
36
|
+
SPEC_FILE = os.path.join(SPEC_DIR, "openapi.json")
|
|
37
|
+
SHA_FILE = os.path.join(SPEC_DIR, "openapi.sha256")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fetch_openapi_document(*, url: str = OPENAPI_URL) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Fetch the upstream Tailscale OpenAPI document text.
|
|
43
|
+
|
|
44
|
+
:param url: The upstream document URL.
|
|
45
|
+
|
|
46
|
+
:returns: The raw document text.
|
|
47
|
+
|
|
48
|
+
:raises: :class:`ConfigurationError` when the upstream URL returns a
|
|
49
|
+
non-200 status.
|
|
50
|
+
"""
|
|
51
|
+
response = requests.get(url, timeout=30)
|
|
52
|
+
if response.status_code != 200:
|
|
53
|
+
err_msg_lines = ["Failed to fetch the Tailscale OpenAPI document."]
|
|
54
|
+
err_msg_lines.append("URL: {}".format(url))
|
|
55
|
+
err_msg_lines.append("STATUS: {}".format(response.status_code))
|
|
56
|
+
err_msg = os.linesep.join(err_msg_lines)
|
|
57
|
+
raise ConfigurationError(err_msg)
|
|
58
|
+
|
|
59
|
+
rtnval = response.text
|
|
60
|
+
return rtnval
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_openapi_document(*, document_text: str) -> Dict[str, Any]:
|
|
64
|
+
"""
|
|
65
|
+
Parse and validate an OpenAPI document text against OpenAPI 3.x.
|
|
66
|
+
|
|
67
|
+
:param document_text: The raw document text (YAML or JSON).
|
|
68
|
+
|
|
69
|
+
:returns: The parsed document dictionary.
|
|
70
|
+
|
|
71
|
+
:raises: :class:`SemanticError` when the document is not a valid
|
|
72
|
+
OpenAPI 3.x document.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
parsed = yaml.safe_load(document_text)
|
|
76
|
+
except yaml.YAMLError as yamlerr:
|
|
77
|
+
err_msg_lines = ["Tailscale OpenAPI document failed to parse."]
|
|
78
|
+
err_msg_lines.append("DETAIL: {}".format(yamlerr))
|
|
79
|
+
err_msg = os.linesep.join(err_msg_lines)
|
|
80
|
+
raise SemanticError(err_msg) from None
|
|
81
|
+
|
|
82
|
+
if not isinstance(parsed, dict):
|
|
83
|
+
raise SemanticError("Tailscale OpenAPI document is not a mapping.")
|
|
84
|
+
|
|
85
|
+
version = parsed.get("openapi")
|
|
86
|
+
if version is None or not str(version).startswith("3."):
|
|
87
|
+
err_msg_lines = ["Document is not an OpenAPI 3.x document."]
|
|
88
|
+
err_msg_lines.append("FOUND VERSION: {}".format(version))
|
|
89
|
+
err_msg = os.linesep.join(err_msg_lines)
|
|
90
|
+
raise SemanticError(err_msg)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
from openapi_spec_validator import validate as _validate_spec
|
|
94
|
+
_validate_spec(parsed)
|
|
95
|
+
except ImportError:
|
|
96
|
+
# The validator is an optional maintainer-side dependency. The
|
|
97
|
+
# version check above is the minimum gate when it is absent.
|
|
98
|
+
pass
|
|
99
|
+
except Exception as valerr:
|
|
100
|
+
err_msg_lines = ["Tailscale OpenAPI document failed schema validation."]
|
|
101
|
+
err_msg_lines.append("DETAIL: {}".format(valerr))
|
|
102
|
+
err_msg = os.linesep.join(err_msg_lines)
|
|
103
|
+
raise SemanticError(err_msg) from None
|
|
104
|
+
|
|
105
|
+
rtnval = parsed
|
|
106
|
+
return rtnval
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def regenerate_models(*, document: Dict[str, Any]) -> int:
|
|
110
|
+
"""
|
|
111
|
+
Regenerate every model file from a validated OpenAPI document.
|
|
112
|
+
|
|
113
|
+
:param document: The parsed and validated OpenAPI document.
|
|
114
|
+
|
|
115
|
+
:returns: The count of model files written.
|
|
116
|
+
"""
|
|
117
|
+
components = document.get("components")
|
|
118
|
+
if components is None:
|
|
119
|
+
raise SemanticError("OpenAPI document has no 'components' section.")
|
|
120
|
+
|
|
121
|
+
schemas = components.get("schemas")
|
|
122
|
+
if schemas is None:
|
|
123
|
+
raise SemanticError("OpenAPI document has no 'components/schemas'.")
|
|
124
|
+
|
|
125
|
+
emitted = emit_all_schemas(schemas=schemas)
|
|
126
|
+
|
|
127
|
+
if not os.path.exists(MODEL_DIR):
|
|
128
|
+
os.makedirs(MODEL_DIR)
|
|
129
|
+
|
|
130
|
+
written = 0
|
|
131
|
+
for module_name in sorted(emitted.keys()):
|
|
132
|
+
_, source_text = emitted[module_name]
|
|
133
|
+
target_path = os.path.join(MODEL_DIR, "{}.py".format(module_name))
|
|
134
|
+
with open(target_path, "w", encoding="utf-8") as model_file:
|
|
135
|
+
model_file.write(source_text)
|
|
136
|
+
written = written + 1
|
|
137
|
+
|
|
138
|
+
init_source = emit_model_init(emitted=emitted)
|
|
139
|
+
init_path = os.path.join(MODEL_DIR, "__init__.py")
|
|
140
|
+
with open(init_path, "w", encoding="utf-8") as init_file:
|
|
141
|
+
init_file.write(init_source)
|
|
142
|
+
|
|
143
|
+
rtnval = written
|
|
144
|
+
return rtnval
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main() -> None:
|
|
148
|
+
"""
|
|
149
|
+
Run the full fetch/validate/regenerate import flow.
|
|
150
|
+
|
|
151
|
+
:returns: ``None``.
|
|
152
|
+
"""
|
|
153
|
+
document_text = fetch_openapi_document()
|
|
154
|
+
document = validate_openapi_document(document_text=document_text)
|
|
155
|
+
|
|
156
|
+
if not os.path.exists(SPEC_DIR):
|
|
157
|
+
os.makedirs(SPEC_DIR)
|
|
158
|
+
|
|
159
|
+
with open(SPEC_FILE, "w", encoding="utf-8") as spec_file:
|
|
160
|
+
spec_file.write(document_text)
|
|
161
|
+
|
|
162
|
+
digest = hashlib.sha256(document_text.encode("utf-8")).hexdigest()
|
|
163
|
+
with open(SHA_FILE, "w", encoding="utf-8") as sha_file:
|
|
164
|
+
sha_file.write(digest)
|
|
165
|
+
sha_file.write(os.linesep)
|
|
166
|
+
|
|
167
|
+
count = regenerate_models(document=document)
|
|
168
|
+
|
|
169
|
+
print("Regenerated {} Tailscale model files.".format(count))
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.. module:: typemap
|
|
3
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
4
|
+
:synopsis: Module that maps OpenAPI ``type``/``format`` declarations
|
|
5
|
+
onto Python type expressions used by the model emitter.
|
|
6
|
+
|
|
7
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__author__ = "Myron Walker"
|
|
11
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
12
|
+
__credits__ = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict
|
|
18
|
+
|
|
19
|
+
from ccai.errors.exceptions import SemanticError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SCALAR_TYPE_MAP: Dict[str, str] = {
|
|
23
|
+
"string": "str",
|
|
24
|
+
"integer": "int",
|
|
25
|
+
"number": "float",
|
|
26
|
+
"boolean": "bool",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def python_type_for_schema(*, schema: Dict[str, Any]) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Resolve a single OpenAPI schema fragment to a Python type expression
|
|
33
|
+
usable as a ``Field`` ``field_type`` argument.
|
|
34
|
+
|
|
35
|
+
:param schema: An OpenAPI schema fragment dictionary.
|
|
36
|
+
|
|
37
|
+
:returns: A Python type-expression string (for example ``"str"`` or
|
|
38
|
+
``"List[str]"``).
|
|
39
|
+
|
|
40
|
+
:raises: :class:`SemanticError` when the fragment declares a type
|
|
41
|
+
that the emitter has no mapping for.
|
|
42
|
+
"""
|
|
43
|
+
if schema is None:
|
|
44
|
+
rtnval = "Any"
|
|
45
|
+
return rtnval
|
|
46
|
+
|
|
47
|
+
ref_value = schema.get("$ref")
|
|
48
|
+
if ref_value is not None:
|
|
49
|
+
# Cross-schema references are emitted as quoted forward-ref strings.
|
|
50
|
+
# The ``Field`` descriptor accepts ``Any`` for ``field_type`` so a
|
|
51
|
+
# string class name is a valid, import-order-independent type tag.
|
|
52
|
+
ref_name = ref_value.rsplit("/", 1)[-1]
|
|
53
|
+
rtnval = "'Tailscale{}'".format(ref_name)
|
|
54
|
+
return rtnval
|
|
55
|
+
|
|
56
|
+
declared_type = schema.get("type")
|
|
57
|
+
|
|
58
|
+
if declared_type is None:
|
|
59
|
+
# allOf / oneOf / untyped fragments degrade to Any so the emitter
|
|
60
|
+
# never produces an unimportable file.
|
|
61
|
+
rtnval = "Any"
|
|
62
|
+
return rtnval
|
|
63
|
+
|
|
64
|
+
if isinstance(declared_type, list):
|
|
65
|
+
# OpenAPI 3.1 allows a list of types (commonly ["string", "null"]).
|
|
66
|
+
non_null = [entry for entry in declared_type if entry != "null"]
|
|
67
|
+
if len(non_null) == 1:
|
|
68
|
+
inner_schema = dict(schema)
|
|
69
|
+
inner_schema["type"] = non_null[0]
|
|
70
|
+
rtnval = python_type_for_schema(schema=inner_schema)
|
|
71
|
+
return rtnval
|
|
72
|
+
rtnval = "Any"
|
|
73
|
+
return rtnval
|
|
74
|
+
|
|
75
|
+
if declared_type == "array":
|
|
76
|
+
item_schema = schema.get("items")
|
|
77
|
+
item_type = python_type_for_schema(schema=item_schema)
|
|
78
|
+
rtnval = "List[{}]".format(item_type)
|
|
79
|
+
return rtnval
|
|
80
|
+
|
|
81
|
+
if declared_type == "object":
|
|
82
|
+
rtnval = "Dict[str, Any]"
|
|
83
|
+
return rtnval
|
|
84
|
+
|
|
85
|
+
if declared_type in SCALAR_TYPE_MAP:
|
|
86
|
+
if declared_type == "string":
|
|
87
|
+
declared_format = schema.get("format")
|
|
88
|
+
if declared_format == "date-time":
|
|
89
|
+
rtnval = "datetime"
|
|
90
|
+
return rtnval
|
|
91
|
+
rtnval = SCALAR_TYPE_MAP[declared_type]
|
|
92
|
+
return rtnval
|
|
93
|
+
|
|
94
|
+
err_msg_lines = ["Unmapped OpenAPI type encountered by the emitter."]
|
|
95
|
+
err_msg_lines.append("TYPE: {}".format(declared_type))
|
|
96
|
+
err_msg = os.linesep.join(err_msg_lines)
|
|
97
|
+
raise SemanticError(err_msg)
|
|
File without changes
|