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.
Files changed (55) hide show
  1. ccai/restclient/tailscale/__init__.py +21 -0
  2. ccai/restclient/tailscale/_codegen/__init__.py +11 -0
  3. ccai/restclient/tailscale/_codegen/emitter.py +288 -0
  4. ccai/restclient/tailscale/_codegen/import_openapi.py +174 -0
  5. ccai/restclient/tailscale/_codegen/typemap.py +97 -0
  6. ccai/restclient/tailscale/_spec/.gitkeep +0 -0
  7. ccai/restclient/tailscale/_spec/openapi.json +6134 -0
  8. ccai/restclient/tailscale/_spec/openapi.sha256 +1 -0
  9. ccai/restclient/tailscale/apigroups/__init__.py +11 -0
  10. ccai/restclient/tailscale/apigroups/aclsgroup.py +93 -0
  11. ccai/restclient/tailscale/apigroups/devicesgroup.py +209 -0
  12. ccai/restclient/tailscale/apigroups/dnsgroup.py +180 -0
  13. ccai/restclient/tailscale/apigroups/groupsupport.py +65 -0
  14. ccai/restclient/tailscale/apigroups/keysgroup.py +211 -0
  15. ccai/restclient/tailscale/apigroups/posturegroup.py +125 -0
  16. ccai/restclient/tailscale/apigroups/tailnetsgroup.py +75 -0
  17. ccai/restclient/tailscale/aspectstailscale.py +108 -0
  18. ccai/restclient/tailscale/exceptions.py +122 -0
  19. ccai/restclient/tailscale/model/__init__.py +69 -0
  20. ccai/restclient/tailscale/model/tailscaleawsexternalid.py +33 -0
  21. ccai/restclient/tailscale/model/tailscaleconfigurationauditlog.py +41 -0
  22. ccai/restclient/tailscale/model/tailscaleconnectioncounts.py +36 -0
  23. ccai/restclient/tailscale/model/tailscalecontact.py +34 -0
  24. ccai/restclient/tailscale/model/tailscaledevice.py +62 -0
  25. ccai/restclient/tailscale/model/tailscaledeviceinvite.py +44 -0
  26. ccai/restclient/tailscale/model/tailscaledevicepostureattributes.py +31 -0
  27. ccai/restclient/tailscale/model/tailscaledeviceroutes.py +31 -0
  28. ccai/restclient/tailscale/model/tailscalednsconfiguration.py +33 -0
  29. ccai/restclient/tailscale/model/tailscalednsconfigurationpreferences.py +31 -0
  30. ccai/restclient/tailscale/model/tailscalednsconfigurationresolver.py +31 -0
  31. ccai/restclient/tailscale/model/tailscalednspreferences.py +28 -0
  32. ccai/restclient/tailscale/model/tailscalednssearchpaths.py +30 -0
  33. ccai/restclient/tailscale/model/tailscaleerror.py +28 -0
  34. ccai/restclient/tailscale/model/tailscalekey.py +50 -0
  35. ccai/restclient/tailscale/model/tailscalekeycapabilities.py +32 -0
  36. ccai/restclient/tailscale/model/tailscalelogstreamendpointconfiguration.py +50 -0
  37. ccai/restclient/tailscale/model/tailscalelogstreamendpointpublishingstatus.py +41 -0
  38. ccai/restclient/tailscale/model/tailscalenetworkflowlog.py +37 -0
  39. ccai/restclient/tailscale/model/tailscalepostureintegration.py +39 -0
  40. ccai/restclient/tailscale/model/tailscaleservicehostinfo.py +34 -0
  41. ccai/restclient/tailscale/model/tailscalesplitdns.py +29 -0
  42. ccai/restclient/tailscale/model/tailscaletailnetsettings.py +42 -0
  43. ccai/restclient/tailscale/model/tailscaleuser.py +44 -0
  44. ccai/restclient/tailscale/model/tailscaleuserinvite.py +39 -0
  45. ccai/restclient/tailscale/model/tailscalevipserviceapproval.py +33 -0
  46. ccai/restclient/tailscale/model/tailscalevipserviceinfo.py +36 -0
  47. ccai/restclient/tailscale/model/tailscalevipserviceinfoput.py +27 -0
  48. ccai/restclient/tailscale/model/tailscalewebhook.py +38 -0
  49. ccai/restclient/tailscale/oauthbearer.py +174 -0
  50. ccai/restclient/tailscale/py.typed +0 -0
  51. ccai/restclient/tailscale/tailscaleclient.py +514 -0
  52. codecraftai_restclient_tailscale-1.2.0.dist-info/METADATA +99 -0
  53. codecraftai_restclient_tailscale-1.2.0.dist-info/RECORD +55 -0
  54. codecraftai_restclient_tailscale-1.2.0.dist-info/WHEEL +4 -0
  55. 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