Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-codegen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- "Dockerfile"
- "Makefile"
- "scripts/**"
workflow_dispatch:

Expand Down
14 changes: 12 additions & 2 deletions .github/workflows/update-schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,20 @@ jobs:
docker run --rm \
-v /tmp/schemas:/schemas:ro \
-v ${{ github.workspace }}:/output \
$CODEGEN_IMAGE \
/schemas /output
$CODEGEN_IMAGE
echo "${{ steps.schema.outputs.latest }}" > .schema-version

- uses: actions/setup-python@v5
if: steps.schema.outputs.changed == 'true'
with:
python-version: "3.12"

- name: Validate
if: steps.schema.outputs.changed == 'true'
run: |
pip install -e ".[dev]"
make validate

- name: Open Pull Request
if: steps.schema.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v6
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Generated code (temporary)
generated/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
Expand Down
16 changes: 4 additions & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
FROM python:3.12-slim-bookworm

# Install Node.js (for openapi-generator-cli) and Java (runtime dependency)
RUN apt-get update && \
apt-get install -y --no-install-recommends nodejs npm default-jre-headless && \
apt-get install -y --no-install-recommends make && \
rm -rf /var/lib/apt/lists/*

# Install openapi-generator-cli
RUN npm install -g @openapitools/openapi-generator-cli
RUN pip install --no-cache-dir datamodel-code-generator ruff

# Install ruff
RUN pip install --no-cache-dir ruff

# Copy scripts into the image
COPY scripts/ /opt/scripts/
RUN chmod +x /opt/scripts/*.sh

ENTRYPOINT ["/opt/scripts/generate.sh"]
WORKDIR /output
ENTRYPOINT ["make", "generate", "SCHEMA_DIR=/schemas"]
54 changes: 42 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,18 +1,48 @@
CODEGEN_IMAGE := ghcr.io/nrel-sienna/power-openapi-models/codegen:latest
SCHEMA_DIR ?= ../SiennaSchemas
CODEGEN_IMAGE ?= ghcr.io/nrel-sienna/power-openapi-models/codegen:latest
PKG_DIR := src/power_openapi_models
CODEGEN := datamodel-codegen --input-file-type openapi \
--output-model-type pydantic_v2.BaseModel \
--formatters ruff-format \
--use-enum-values-in-discriminator \
--disable-timestamp
CORE_REF := --external-ref-mapping "Core/common.json=power_openapi_models.core.models"

.PHONY: generate generate-local
.PHONY: generate generate-docker clean validate

generate:
docker run --rm \
-v $(CURDIR)/openapi:/schemas:ro \
-v $(CURDIR):/output \
$(CODEGEN_IMAGE) \
/schemas /output
@echo "==> Generating core"
$(CODEGEN) \
--input $(SCHEMA_DIR)/openapi-core.json \
--output $(PKG_DIR)/core/models.py

@echo "==> Generating operations"
$(CODEGEN) $(CORE_REF) \
--input $(SCHEMA_DIR)/openapi-operations.json \
--output $(PKG_DIR)/operations/models.py

@echo "==> Generating investments"
$(CODEGEN) $(CORE_REF) \
--input $(SCHEMA_DIR)/openapi-investments.json \
--output $(PKG_DIR)/investments/models.py

generate-local:
docker build -t $(CODEGEN_IMAGE) .
@echo "==> Generating dynamics"
$(CODEGEN) $(CORE_REF) \
--input $(SCHEMA_DIR)/openapi-dynamics.json \
--output $(PKG_DIR)/dynamics/models.py

@echo "==> Post-processing"
python scripts/postprocess.py

generate-docker:
docker run --rm \
-v $(CURDIR)/openapi:/schemas:ro \
-v $(abspath $(SCHEMA_DIR)):/schemas:ro \
-v $(CURDIR):/output \
$(CODEGEN_IMAGE) \
/schemas /output
$(CODEGEN_IMAGE)

clean:
rm -f $(PKG_DIR)/*/models.py

validate:
python -c "import power_openapi_models; print('Import OK')"
pytest tests/ -v
Empty file removed openapi/core.yaml
Empty file.
Empty file removed openapi/dynamics.yaml
Empty file.
Empty file removed openapi/investments.yaml
Empty file.
Empty file removed openapi/operations.yaml
Empty file.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[build-system]
requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
build-backend = "setuptools.backends._legacy:_Backend"
build-backend = "setuptools.build_meta"

[project]
name = "power-openapi-models"
Expand All @@ -15,6 +15,7 @@ dependencies = [
dev = [
"ruff",
"pytest",
"datamodel-code-generator",
]

[tool.setuptools.packages.find]
Expand Down
44 changes: 0 additions & 44 deletions scripts/generate.sh

This file was deleted.

120 changes: 120 additions & 0 deletions scripts/postprocess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Post-process generated models to fix known datamodel-codegen issues.

Each fix targets a specific known problem. New issues should be added as
separate fix functions. When an upstream fix lands, the corresponding
function can be removed.

After applying fixes, the script scans for potential new issues and warns
about them without attempting an automatic fix.
"""

import re
import sys
from pathlib import Path

PKG_DIR = Path(__file__).parent.parent / "src" / "power_openapi_models"

PRIMITIVES = {"float", "int", "str", "bool"}


# ---------------------------------------------------------------------------
# Fixes — each function takes file content and returns (modified_content, bool)
# where the bool indicates whether a change was made.
# ---------------------------------------------------------------------------


def fix_thermal_generation_cost_start_up(content: str) -> tuple[str, bool]:
"""Remove discriminator from ThermalGenerationCost.start_up field.

datamodel-codegen emits discriminator="startup_stages_type" on the
start_up field, but its type is ``float | StartUpStages``. Pydantic
requires all discriminated-union variants to be BaseModel subclasses,
so the discriminator must be removed.
"""
fixed = re.sub(
r'(start_up: float \| StartUpStages = Field\([^)]*?)'
r',\s*discriminator="startup_stages_type"',
r'\1',
content,
flags=re.DOTALL,
)
return fixed, fixed != content


FIXES = [
fix_thermal_generation_cost_start_up,
]


# ---------------------------------------------------------------------------
# Warnings — detect potential new issues without fixing them.
# ---------------------------------------------------------------------------


def _has_primitive_in_union(type_str: str) -> bool:
parts = [p.strip() for p in type_str.split("|")]
return any(p in PRIMITIVES for p in parts)


def warn_primitive_discriminators(content: str, path: Path) -> int:
"""Warn about any discriminated union field that includes a primitive type.

This catches new instances of the same class of bug so they can be
addressed with a targeted fix.
"""
warnings = 0
for match in re.finditer(
r"^\s+(\w+):\s*(.+?)\s*=\s*Field\(", content, re.MULTILINE
):
field_name = match.group(1)
type_str = match.group(2)
if _has_primitive_in_union(type_str) and "discriminator=" in content[match.start():]:
# Check that the discriminator belongs to this Field() call
field_start = match.start()
paren_end = content.find(")", field_start)
field_text = content[field_start : paren_end + 1] if paren_end != -1 else ""
if "discriminator=" in field_text:
print(
f" WARNING: {path}:{field_name} — primitive in "
f"discriminated union ({type_str})",
file=sys.stderr,
)
warnings += 1
return warnings


WARNINGS = [
warn_primitive_discriminators,
]


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def main() -> None:
warnings = 0
for models_file in sorted(PKG_DIR.glob("*/models.py")):
content = models_file.read_text()

for fix in FIXES:
content, changed = fix(content)
if changed:
models_file.write_text(content)
print(f" Fixed ({fix.__name__}): {models_file}")

for warn in WARNINGS:
warnings += warn(content, models_file)

if warnings:
print(
f"\n {warnings} warning(s): new issues detected that may need fixes.",
file=sys.stderr,
)
sys.exit(1)


if __name__ == "__main__":
main()
79 changes: 0 additions & 79 deletions scripts/reorganize_stubs.sh

This file was deleted.

3 changes: 1 addition & 2 deletions src/power_openapi_models/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Auto-generated stubs for the core domain."""

from power_openapi_models.core import models
from power_openapi_models.core import apis

__all__ = ["models", "apis"]
__all__ = ["models"]
1 change: 0 additions & 1 deletion src/power_openapi_models/core/apis/__init__.py

This file was deleted.

Loading