Skip to content

Commit 321aa02

Browse files
helltfullstopdev
andcommitted
init openapi pydantic modules
Co-authored-by: fullstopdev <m_ailane@esi.dz>
1 parent bc5a7cc commit 321aa02

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+17105
-2
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,5 @@ cython_debug/
169169

170170
# PyPI configuration file
171171
.pypirc
172+
173+
.envrc

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12

README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,27 @@
1-
# pydantic-eda
2-
Pydantic models for EDA OpenAPI spec
1+
# Pydantic EDA
2+
3+
**WIP!**
4+
5+
Pydantic models for EDA OpenAPI spec. Models are generated for the EDA Core API as well as for the apps shipped with EDA Playground at the time of the generation.
6+
7+
## Usage
8+
9+
## Generation
10+
11+
Store Github auth token in a `GH_AUTH_TOKEN` environment variable. For example, with `gh` cli:
12+
13+
```
14+
export GH_AUTH_TOKEN=$(gh auth token)
15+
```
16+
17+
Install dev dependencies:
18+
19+
```
20+
uv add --dev 'datamodel-code-generator[http]' rich ruff httpx
21+
```
22+
23+
Generate models for a specific version of openapi repo:
24+
25+
```
26+
python gen_models.py --version v24.12.1
27+
```

cached_specs.json

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
{
2+
"v24.12.1": [
3+
{
4+
"name": "core",
5+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/core/core.json"
6+
},
7+
{
8+
"name": "aaa",
9+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/aaa.eda.nokia.com/v1alpha1/aaa.json"
10+
},
11+
{
12+
"name": "appstore",
13+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/appstore.eda.nokia.com/v1/appstore.json"
14+
},
15+
{
16+
"name": "bootstrap",
17+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/bootstrap.eda.nokia.com/v1alpha1/bootstrap.json"
18+
},
19+
{
20+
"name": "components",
21+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/components.eda.nokia.com/v1alpha1/components.json"
22+
},
23+
{
24+
"name": "config",
25+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/config.eda.nokia.com/v1alpha1/config.json"
26+
},
27+
{
28+
"name": "core",
29+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/core.eda.nokia.com/v1/core.json"
30+
},
31+
{
32+
"name": "fabrics",
33+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/fabrics.eda.nokia.com/v1alpha1/fabrics.json"
34+
},
35+
{
36+
"name": "filters",
37+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/filters.eda.nokia.com/v1alpha1/filters.json"
38+
},
39+
{
40+
"name": "interfaces",
41+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/interfaces.eda.nokia.com/v1alpha1/interfaces.json"
42+
},
43+
{
44+
"name": "oam",
45+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/oam.eda.nokia.com/v1alpha1/oam.json"
46+
},
47+
{
48+
"name": "protocols",
49+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/protocols.eda.nokia.com/v1alpha1/protocols.json"
50+
},
51+
{
52+
"name": "qos",
53+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/qos.eda.nokia.com/v1alpha1/qos.json"
54+
},
55+
{
56+
"name": "routing",
57+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/routing.eda.nokia.com/v1alpha1/routing.json"
58+
},
59+
{
60+
"name": "routingpolicies",
61+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/routingpolicies.eda.nokia.com/v1alpha1/routingpolicies.json"
62+
},
63+
{
64+
"name": "security",
65+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/security.eda.nokia.com/v1alpha1/security.json"
66+
},
67+
{
68+
"name": "services",
69+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/services.eda.nokia.com/v1alpha1/services.json"
70+
},
71+
{
72+
"name": "siteinfo",
73+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/siteinfo.eda.nokia.com/v1alpha1/siteinfo.json"
74+
},
75+
{
76+
"name": "system",
77+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/system.eda.nokia.com/v1alpha1/system.json"
78+
},
79+
{
80+
"name": "timing",
81+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/timing.eda.nokia.com/v1alpha1/timing.json"
82+
},
83+
{
84+
"name": "topologies",
85+
"url": "https://raw.githubusercontent.com/eda-labs/openapi/v24.12.1/apps/topologies.eda.nokia.com/v1alpha1/topologies.json"
86+
}
87+
]
88+
}

gen_models.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import json
5+
import logging
6+
import os
7+
import subprocess
8+
from pathlib import Path
9+
from venv import logger
10+
11+
import httpx
12+
from httpx import HTTPStatusError, RequestError
13+
from rich.logging import RichHandler
14+
from rich.traceback import install
15+
16+
# Replace the basic logging config with Rich handler
17+
logging.basicConfig(
18+
level=logging.INFO,
19+
format="%(message)s",
20+
handlers=[RichHandler(rich_tracebacks=True, markup=True)],
21+
)
22+
23+
logging.getLogger("httpx").setLevel(logging.INFO)
24+
25+
install(show_locals=False)
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class OpenAPIDiscovery:
31+
def __init__(
32+
self,
33+
input_dir: str,
34+
output_dir: str,
35+
version: str,
36+
verbose: bool,
37+
use_cache: bool,
38+
):
39+
self.base_url = "https://api.github.com/repos/eda-labs/openapi"
40+
self.raw_base = f"https://raw.githubusercontent.com/eda-labs/openapi/{version}"
41+
self.gh_token = os.environ.get("GH_AUTH_TOKEN")
42+
if self.gh_token == "":
43+
logger.error("GH_AUTH_TOKEN environment variable is not set.")
44+
exit(1)
45+
46+
self.headers = {
47+
"Accept": "application/vnd.github.v3+json",
48+
"Authorization": f"Bearer {self.gh_token}",
49+
}
50+
self.cache_file = Path("cached_specs.json")
51+
self.input_dir = Path(input_dir)
52+
self.output_dir = Path(output_dir)
53+
self.verbose = verbose
54+
self.use_cache = use_cache
55+
self.version = version
56+
57+
if self.verbose:
58+
logger.setLevel(logging.DEBUG)
59+
60+
# Configure retry strategy with httpx
61+
transport = httpx.HTTPTransport(
62+
retries=3,
63+
)
64+
65+
self.client = httpx.Client(
66+
timeout=30.0,
67+
transport=transport,
68+
headers=self.headers,
69+
follow_redirects=True,
70+
)
71+
72+
def get_contents(self, path=""):
73+
url = f"{self.base_url}/contents/{path}"
74+
logger.info(f"Fetching contents from: {url}")
75+
76+
try:
77+
response = self.client.get(url)
78+
response.raise_for_status()
79+
80+
# Rate limiting info
81+
remaining = response.headers.get("X-RateLimit-Remaining")
82+
if remaining:
83+
logger.info(f"API calls remaining: {remaining}")
84+
85+
content = response.json()
86+
if isinstance(content, str):
87+
raise ValueError(f"Unexpected response format: {content}")
88+
return content
89+
90+
except HTTPStatusError as e:
91+
logger.error(f"HTTP error occurred: {e}")
92+
raise
93+
except RequestError as e:
94+
logger.error(f"Request error occurred: {e}")
95+
raise
96+
97+
def load_cached_specs(self) -> dict[str, list[dict[str, str]]]:
98+
try:
99+
if self.cache_file.exists() and self.cache_file.stat().st_size > 0:
100+
with open(self.cache_file) as f:
101+
cached = json.load(f)
102+
return cached
103+
except (json.JSONDecodeError, OSError) as e:
104+
logger.warning(f"Cache read error: {e}")
105+
return {"": []}
106+
107+
def save_specs_cache(self, specs):
108+
try:
109+
with open(self.cache_file, "w") as f:
110+
json.dump(specs, f, indent=2)
111+
except OSError as e:
112+
logger.error(f"Failed to save cache: {e}")
113+
114+
def discover_specs(self, use_cache=True) -> dict[str, list[dict[str, str]]]:
115+
if use_cache:
116+
cached = self.load_cached_specs()
117+
if cached:
118+
logger.info("Using cached specs")
119+
return cached
120+
121+
specs = {self.version: []}
122+
specs_for_version = specs[self.version]
123+
124+
# Check core path
125+
core_specs = self.get_contents("core")
126+
for spec in core_specs:
127+
if spec.get("type") == "file" and spec.get("name", "").endswith(".json"):
128+
specs_for_version.append(
129+
{
130+
"name": Path(spec["name"]).stem,
131+
"url": f"{self.raw_base}/core/{spec['name']}",
132+
}
133+
)
134+
135+
# Check apps path
136+
apps = self.get_contents("apps")
137+
for app in apps:
138+
if app.get("type") == "dir":
139+
versions = self.get_contents(app["path"])
140+
for version in versions:
141+
if version.get("type") == "dir":
142+
files = self.get_contents(version["path"])
143+
for file in files:
144+
if file.get("name", "").endswith(".json"):
145+
specs_for_version.append(
146+
{
147+
"name": app["name"].split(".")[0],
148+
"url": f"{self.raw_base}/{file['path']}",
149+
}
150+
)
151+
152+
if specs:
153+
self.save_specs_cache(specs)
154+
155+
return specs
156+
157+
def generate_models(self):
158+
output_dir = self.output_dir
159+
output_dir.mkdir(exist_ok=True)
160+
specs: dict[str, list[dict[str, str]]] = self.discover_specs(
161+
use_cache=self.use_cache
162+
)
163+
164+
if not specs:
165+
logger.warning("No specs found!")
166+
return
167+
168+
if self.version not in specs:
169+
logger.warning(f"No specs found for version {self.version}")
170+
return
171+
172+
for spec in specs[self.version]:
173+
url_parts = spec["url"].split("/")
174+
module_name = url_parts[-1].replace(".json", "")
175+
176+
cmd = [
177+
"datamodel-codegen",
178+
"--url",
179+
spec["url"],
180+
"--output-model-type",
181+
"pydantic_v2.BaseModel",
182+
"--use-annotated",
183+
"--enum-field-as-literal",
184+
"all",
185+
"--output",
186+
str(output_dir),
187+
]
188+
189+
# the core API should use the file name as the output of the DMCG command
190+
# the core app does not have apps dir in its URL
191+
if "apps" not in url_parts and module_name == "core":
192+
cmd[-1] = str(output_dir) + "/core.py"
193+
194+
try:
195+
logger.info(f"Generating models for {module_name}...")
196+
subprocess.run(cmd, check=True)
197+
except subprocess.CalledProcessError as e:
198+
logger.error(f"Error generating models for {module_name}: {e}")
199+
200+
def __del__(self):
201+
self.client.close()
202+
203+
204+
if __name__ == "__main__":
205+
parser = argparse.ArgumentParser(
206+
description="Discover OpenAPI specifications and generate Pydantic models."
207+
)
208+
parser.add_argument(
209+
"--input",
210+
type=str,
211+
default="./openapi_specs",
212+
help="Path to the OpenAPI specifications directory. Default: ./openapi_specs",
213+
)
214+
parser.add_argument(
215+
"--output",
216+
type=str,
217+
default="./pydantic_eda",
218+
help="Path to the output directory.",
219+
)
220+
parser.add_argument(
221+
"--version",
222+
type=str,
223+
default="main",
224+
help="openapi repo version (tag) to get the models from. Default: main",
225+
)
226+
parser.add_argument(
227+
"--verbose",
228+
action="store_true",
229+
help="Enable verbose logging. Default: False",
230+
)
231+
parser.add_argument(
232+
"--no-cache",
233+
action="store_true",
234+
help="Force fresh discovery by ignoring the cache. Default: False",
235+
)
236+
237+
args = parser.parse_args()
238+
239+
discovery = OpenAPIDiscovery(
240+
input_dir=args.input,
241+
output_dir=args.output,
242+
version=args.version,
243+
verbose=args.verbose,
244+
use_cache=not args.no_cache,
245+
)
246+
discovery.generate_models()

0 commit comments

Comments
 (0)