diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6f45140..909616f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,45 +7,36 @@ on: branches: [ main ] jobs: - flake8_py3: + flake8: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: '3.11' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest - - - name: Run flake8 (suo) - uses: julianwachholz/flake8-action@v2 - with: - checkName: 'flake8_py3' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + pip install flake8 + - name: Run flake8 + run: flake8 py_models_parser/ tests/ - tests: + tox: runs-on: ubuntu-latest - needs: [flake8_py3] + needs: [flake8] strategy: matrix: - python: [3.7, 3.8, 3.9, '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python }} + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install poetry - poetry install - env: - POETRY_VIRTUALENVS_CREATE: false - - name: Test with pytest - run: | - pytest tests/ -vv + pip install tox tox-gh-actions + - name: Run tox + run: tox -e py$(echo ${{ matrix.python-version }} | tr -d '.') diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2641b80 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,219 @@ +# Архитектура py-models-parser + +## О проекте + +**Py-Models-Parser** — текстовый парсер Python моделей и определений таблиц, который извлекает структурную информацию из различных ORM-фреймворков без необходимости импортировать исходный код. + +Парсер использует PEG (Parsing Expression Grammar) через библиотеку `parsimonious` для анализа текста кода как строк. + +## Поддерживаемые модели + +- SQLAlchemy ORM +- Gino ORM +- Tortoise ORM +- Encode ORM +- Django ORM Models +- Pydantic +- Python Enums +- Pony ORM +- Piccolo ORM +- Pydal Tables (Web2Py) +- Python Dataclasses +- Чистые Python классы +- OpenAPI 3.0/Swagger specifications + +## Структура проекта + +``` +py-models-parser/ +├── py_models_parser/ # Основной пакет +│ ├── __init__.py # Публичный API +│ ├── core.py # Основная логика парсинга +│ ├── grammar.py # PEG грамматика +│ ├── visitor.py # Visitor для преобразования AST +│ ├── types.py # Определения типов и триггеры +│ ├── utils.py # Утилиты +│ ├── cli.py # CLI интерфейс +│ └── parsers/ +│ ├── pydal.py # Специализированный парсер для Pydal +│ └── openapi.py # Парсер OpenAPI/Swagger спецификаций +├── tests/ # Тесты +│ ├── test_*.py # Тесты для каждого типа моделей +│ └── data/ # Тестовые данные +├── tox.ini # Конфигурация tox для мультиверсионного тестирования +└── pyproject.toml # Конфигурация Poetry +``` + +## Основные компоненты + +### 1. Public API (`__init__.py`) + +Экспортирует функции: +- `parse(models: str)` — парсинг строки с Python моделями +- `parse_from_file(file_path)` — парсинг Python моделей из файла +- `dump_result(output, file_path)` — сохранение результатов в JSON +- `parse_openapi(content: str)` — парсинг OpenAPI спецификации из строки +- `parse_openapi_file(file_path)` — парсинг OpenAPI спецификации из файла + +### 2. Core (`core.py`) + +Главный модуль с логикой парсинга: + +| Функция | Назначение | +|---------|-----------| +| `parse()` | Главная функция парсинга | +| `parse_from_file()` | Парсинг из файла | +| `pre_processing()` | Удаление импортов, комментариев, декораторов | +| `get_models_type()` | Определение типа модели | +| `sqlalchemy_type_identify()` | Различие SQLAlchemy ORM и Core | +| `format_ouput()` | Постобработка результатов | +| `process_models_attr()` | Разрешение ссылок на другие модели | +| `clear_parents()` | Удаление служебной информации | + +### 3. Grammar (`grammar.py`) + +PEG-грамматика для парсинга: + +``` +expr → (class/if_else/call_result/...)* - корневое выражение +class → class_def attr_def* funct_def* - определение класса +class_def → class_name args? ":"* - сигнатура класса +attr_def → id type? ("=" right_part)* - атрибут +type → ":" (id args_in_brackets / id) - аннотация типа +args → "(" (list/call_result/...)* ")" - аргументы +``` + +### 4. Visitor (`visitor.py`) + +Класс `Visitor(NodeVisitor)` для преобразования AST в структуры данных: + +| Метод | Назначение | +|-------|-----------| +| `visit_class_name()` | Извлечение названия класса | +| `visit_class_def()` | Обработка определения класса | +| `visit_attr_def()` | Обработка атрибута | +| `visit_right_part()` | Анализ правой части присваивания | +| `visit_type()` | Парсинг аннотаций типов | +| `extract_orm_attr()` | Извлечение параметров ORM | +| `_process_attr()` | Классификация атрибутов | + +### 5. Types (`types.py`) + +Конфигурация для распознавания типов моделей: + +```python +orm_triggers = ["Column", "Field", "relationship"] +pony_orm_fields = ["Required", "Set", "Optional", "PrimaryKey"] +ormar_and_piccollo_types = ["Integer", "String", "Text", ...] +``` + +### 6. CLI (`cli.py`) + +Интерфейс командной строки: + +```bash +pmp path_to_models.py [-d output.json] +``` + +## Поток данных + +``` +Входной Python-код + │ + ▼ +┌─────────────────────┐ +│ pre_processing() │ Удаление импортов, комментариев +│ get_models_type() │ Определение типа модели +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ grammar.parse() │ PEG парсинг → AST +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ Visitor.visit() │ Обход AST +│ extract_orm_attr() │ Распознавание ORM-параметров +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ format_ouput() │ Постобработка +│ process_models_attr │ +│ clear_parents() │ +└─────────┬───────────┘ + │ + ▼ + Структурированный JSON +``` + +## Выходной формат + +```python +{ + "name": str, # Имя класса/модели + "parents": [str], # Родительские классы + "attrs": [ + { + "name": str, # Имя атрибута + "type": str, # Тип данных + "default": str, # Значение по умолчанию + "properties": { # Метаданные + "primary_key": bool, + "nullable": bool, + "foreign_key": str, + ... + } + } + ], + "properties": { # Свойства модели + "table_name": str, + "table_args": str, + ... + } +} +``` + +## Зависимости + +**Runtime:** +- `parsimonious` ^0.10.0 — PEG парсер для Python моделей +- `pyyaml` ^6.0 — парсер YAML для OpenAPI спецификаций + +**Development:** +- `pytest` ^7.4 +- `tox` — мультиверсионное тестирование + +**Python:** 3.9, 3.10, 3.11, 3.12, 3.13 + +## Использование + +```python +# Парсинг Python моделей из строки +from py_models_parser import parse +result = parse(models_string) + +# Парсинг Python моделей из файла +from py_models_parser import parse_from_file +result = parse_from_file("path/to/models.py") + +# Парсинг OpenAPI спецификации +from py_models_parser import parse_openapi, parse_openapi_file +result = parse_openapi(openapi_yaml_string) +result = parse_openapi_file("path/to/openapi.yaml") +``` + +```bash +# CLI +pmp models.py -d output.json +``` + +## Расширение + +Для добавления поддержки нового ORM: + +1. Добавить триггеры в `types.py` +2. Расширить грамматику в `grammar.py` (при необходимости) +3. Добавить методы в `visitor.py` для специфики ORM +4. Создать специализированный парсер в `parsers/` (опционально) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 0d3647d..49f4263 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,15 @@ +**v1.0.0** +Breaking Changes: +1. Dropped support for Python 3.7 and 3.8 +2. Minimum required Python version is now 3.9 + +New Features: +1. Added support for Python 3.12 and 3.13 +2. Added OpenAPI 3.0/Swagger specification parser (parse_openapi, parse_openapi_file) +3. Added tox for multi-version testing +4. Added ARCHITECTURE.md with project documentation +5. Added pyyaml dependency for OpenAPI parsing + **v0.7.0** Updates: 1. Added support for latest version of parsimonious. diff --git a/README.md b/README.md index 5d6e358..702c118 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,18 @@ For model from point 1 (above) library will produce the result: ## Changelog +**v1.0.0** +Breaking Changes: +1. Dropped support for Python 3.7 and 3.8 +2. Minimum required Python version is now 3.9 + +New Features: +1. Added support for Python 3.12 and 3.13 +2. Added OpenAPI 3.0/Swagger specification parser (parse_openapi, parse_openapi_file) +3. Added tox for multi-version testing +4. Added ARCHITECTURE.md with project documentation +5. Added pyyaml dependency for OpenAPI parsing + **v0.7.0** Updates: 1. Added support for latest version of parsimonious. diff --git a/docs/README.rst b/docs/README.rst index 4ab5bc6..64feefa 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -36,7 +36,8 @@ Py-Models-Parser can parse & extract information from models & table definitions * Piccolo ORM models (https://piccolo-orm.readthedocs.io/en/latest/piccolo/schema/defining.html), * Pydal Tables definitions (http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#The-DAL-A-quick-tour), * Python Dataclasses (https://docs.python.org/3/library/dataclasses.html), -* pure Python Classes (https://docs.python.org/3/tutorial/classes.html#class-objects) +* pure Python Classes (https://docs.python.org/3/tutorial/classes.html#class-objects), +* OpenAPI 3.0/Swagger specifications (https://swagger.io/specification/) Number of supported models will be increased, check 'TODO' section, if you want to have support of different models types - please open the issue. @@ -124,6 +125,32 @@ Library detect automaticaly that type of models you tries to parse. You can chec result = parse_from_file(file_path) +#. Parse OpenAPI/Swagger specifications: + +.. code-block:: python + + + from py_models_parser import parse_openapi, parse_openapi_file + + # Parse from string + openapi_spec = """ + openapi: "3.0.0" + components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + """ + result = parse_openapi(openapi_spec) + + # Or parse from file (supports both YAML and JSON) + result = parse_openapi_file("path/to/openapi.yaml") + + #. Parse models from file with command line .. code-block:: bash @@ -202,6 +229,19 @@ TODO: in next Release Changelog --------- +**v1.0.0** +Breaking Changes: + +#. Dropped support for Python 3.7 and 3.8 +#. Minimum required Python version is now 3.9 + +New Features: + +#. Added support for Python 3.12 and 3.13 +#. Added OpenAPI 3.0/Swagger specification parser (parse_openapi, parse_openapi_file) +#. Added tox for multi-version testing +#. Added ARCHITECTURE.md with project documentation + **v0.7.0** Updates: diff --git a/py_models_parser/__init__.py b/py_models_parser/__init__.py index 0d3af27..44615f4 100644 --- a/py_models_parser/__init__.py +++ b/py_models_parser/__init__.py @@ -1,3 +1,10 @@ from py_models_parser.core import dump_result, parse, parse_from_file +from py_models_parser.parsers.openapi import parse_openapi, parse_openapi_file -__all__ = ["parse", "parse_from_file", "dump_result"] +__all__ = [ + "parse", + "parse_from_file", + "dump_result", + "parse_openapi", + "parse_openapi_file", +] diff --git a/py_models_parser/parsers/openapi.py b/py_models_parser/parsers/openapi.py new file mode 100644 index 0000000..1e44eb4 --- /dev/null +++ b/py_models_parser/parsers/openapi.py @@ -0,0 +1,255 @@ +"""OpenAPI 3.0 Specification Parser. + +Parses OpenAPI/Swagger schemas and converts them to py-models-parser format. +""" +import json +from typing import Any, Dict, List, Optional + +import yaml + + +# OpenAPI type to Python type mapping +OPENAPI_TYPE_MAP = { + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", + "array": "list", + "object": "dict", +} + +# OpenAPI format to Python type mapping +OPENAPI_FORMAT_MAP = { + "int32": "int", + "int64": "int", + "float": "float", + "double": "float", + "date": "datetime.date", + "date-time": "datetime.datetime", + "time": "datetime.time", + "email": "str", + "uri": "str", + "uuid": "uuid.UUID", + "binary": "bytes", + "byte": "bytes", +} + +# Property mappings from OpenAPI to internal format +PROPERTY_MAPPINGS = { + "description": "description", + "enum": "enum", + "minimum": "minimum", + "maximum": "maximum", + "minLength": "min_length", + "maxLength": "max_length", + "pattern": "pattern", + "nullable": "nullable", + "format": "format", +} + + +def _resolve_ref(ref: str, spec: Dict) -> Optional[Dict]: + """Resolve a $ref pointer to its definition.""" + if not ref.startswith("#/"): + return None + + parts = ref[2:].split("/") + current = spec + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +def _get_ref_type(schema: Dict, spec: Dict, visited: set) -> str: + """Handle $ref type resolution.""" + ref = schema["$ref"] + if ref in visited: + return "Any" + visited.add(ref) + + ref_schema = _resolve_ref(ref, spec) + if ref_schema: + return ref.split("/")[-1] + return "Any" + + +def _get_composite_type( + sub_schemas: List[Dict], + spec: Dict, + visited: set +) -> str: + """Handle allOf/oneOf/anyOf composite types.""" + types = [ + _get_type_from_schema(sub_schema, spec, visited) + for sub_schema in sub_schemas + ] + if len(types) == 1: + return types[0] + return f"Union[{', '.join(types)}]" + + +def _get_type_from_schema( + schema: Dict, + spec: Dict, + visited: Optional[set] = None +) -> str: + """Convert OpenAPI schema to Python type string.""" + if visited is None: + visited = set() + + if "$ref" in schema: + return _get_ref_type(schema, spec, visited) + + if "allOf" in schema: + return _get_composite_type(schema["allOf"], spec, visited) + + if "oneOf" in schema or "anyOf" in schema: + sub_schemas = schema.get("oneOf") or schema.get("anyOf") + return _get_composite_type(sub_schemas, spec, visited) + + schema_type = schema.get("type", "object") + + if schema_type == "array": + items = schema.get("items", {}) + item_type = _get_type_from_schema(items, spec, visited) + return f"List[{item_type}]" + + if "format" in schema: + format_type = OPENAPI_FORMAT_MAP.get(schema["format"]) + if format_type: + return format_type + + if "enum" in schema: + return "str" + + return OPENAPI_TYPE_MAP.get(schema_type, "Any") + + +def _extract_attr_properties( + prop_schema: Dict, + prop_name: str, + required_fields: List[str] +) -> Dict: + """Extract properties from a single attribute schema.""" + properties = {} + + if prop_name in required_fields: + properties["required"] = True + + for openapi_key, internal_key in PROPERTY_MAPPINGS.items(): + if openapi_key in prop_schema: + properties[internal_key] = prop_schema[openapi_key] + + return properties + + +def _extract_properties( + schema: Dict, + spec: Dict, + required_fields: List[str] +) -> List[Dict]: + """Extract properties from schema and convert to attrs format.""" + attrs = [] + properties = schema.get("properties", {}) + + for prop_name, prop_schema in properties.items(): + attr = { + "name": prop_name, + "type": _get_type_from_schema(prop_schema, spec), + "default": prop_schema.get("default"), + "properties": _extract_attr_properties( + prop_schema, prop_name, required_fields + ), + } + attrs.append(attr) + + return attrs + + +def _parse_schema( + name: str, + schema: Dict, + spec: Dict +) -> Dict[str, Any]: + """Parse a single OpenAPI schema into py-models-parser format.""" + required_fields = schema.get("required", []) + + model = { + "name": name, + "parents": [], + "attrs": [], + "properties": {}, + } + + if "allOf" in schema: + for sub_schema in schema["allOf"]: + if "$ref" in sub_schema: + ref_name = sub_schema["$ref"].split("/")[-1] + model["parents"].append(ref_name) + elif "properties" in sub_schema: + required_fields.extend(sub_schema.get("required", [])) + model["attrs"].extend( + _extract_properties(sub_schema, spec, required_fields) + ) + else: + model["attrs"] = _extract_properties(schema, spec, required_fields) + + if "description" in schema: + model["properties"]["description"] = schema["description"] + + if "title" in schema: + model["properties"]["title"] = schema["title"] + + return model + + +def parse_openapi(content: str) -> List[Dict]: + """Parse OpenAPI specification and return list of models. + + Args: + content: OpenAPI specification as YAML or JSON string + + Returns: + List of models in py-models-parser format + """ + try: + spec = yaml.safe_load(content) + except yaml.YAMLError: + try: + spec = json.loads(content) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid OpenAPI specification: {e}") + + if not isinstance(spec, dict): + raise ValueError("Invalid OpenAPI specification: root must be object") + + schemas = {} + + if "components" in spec and "schemas" in spec["components"]: + schemas = spec["components"]["schemas"] + elif "definitions" in spec: + schemas = spec["definitions"] + + models = [] + for name, schema in schemas.items(): + model = _parse_schema(name, schema, spec) + models.append(model) + + return models + + +def parse_openapi_file(file_path: str) -> List[Dict]: + """Parse OpenAPI specification from file. + + Args: + file_path: Path to OpenAPI specification file (YAML or JSON) + + Returns: + List of models in py-models-parser format + """ + with open(file_path, "r") as f: + content = f.read() + return parse_openapi(content) diff --git a/pyproject.toml b/pyproject.toml index c7a6364..12855d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "py-models-parser" -version = "0.7.0" +version = "1.0.0" description = "Parser for Different Python Models (Pydantic, Enums, ORMs: Tortoise, SqlAlchemy, GinoORM, PonyORM, Pydal tables) to extract information about columns(attrs), model, table args,etc in one format." authors = ["Iuliia Volkova "] license = "MIT" @@ -15,21 +15,23 @@ classifiers = [ "Topic :: Utilities", "Topic :: Software Development :: Code Generators", "Topic :: Software Development :: Libraries :: Python Modules", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11" + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" ] [tool.poetry.dependencies] -python = "^3.7" +python = "^3.9" parsimonious = "^0.10.0" +pyyaml = "^6.0" [tool.poetry.dev-dependencies] pytest = "^7.4" twine = "^4.0" m2r = "^0.3.1" +tox = "^4.0" [tool.poetry.scripts] pmp = 'py_models_parser.cli:main' diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 0000000..ee56284 --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,468 @@ +"""Tests for OpenAPI 3.0 parser.""" +import pytest +from py_models_parser import parse_openapi + + +def test_simple_openapi_yaml(): + """Test parsing simple OpenAPI schema in YAML format.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + User: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email +""" + result = parse_openapi(openapi_spec) + + assert len(result) == 1 + assert result[0]["name"] == "User" + assert len(result[0]["attrs"]) == 3 + + id_attr = next(a for a in result[0]["attrs"] if a["name"] == "id") + assert id_attr["type"] == "int" + assert id_attr["properties"]["required"] is True + + name_attr = next(a for a in result[0]["attrs"] if a["name"] == "name") + assert name_attr["type"] == "str" + assert name_attr["properties"]["required"] is True + + email_attr = next(a for a in result[0]["attrs"] if a["name"] == "email") + assert email_attr["type"] == "str" + assert email_attr["properties"].get("format") == "email" + + +def test_openapi_json(): + """Test parsing OpenAPI schema in JSON format.""" + openapi_spec = """ +{ + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0"}, + "components": { + "schemas": { + "Product": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "price": {"type": "number"} + } + } + } + } +} +""" + result = parse_openapi(openapi_spec) + + assert len(result) == 1 + assert result[0]["name"] == "Product" + + price_attr = next(a for a in result[0]["attrs"] if a["name"] == "price") + assert price_attr["type"] == "float" + + +def test_openapi_with_refs(): + """Test parsing OpenAPI schema with $ref references.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + Address: + type: object + properties: + street: + type: string + city: + type: string + Person: + type: object + properties: + name: + type: string + address: + $ref: '#/components/schemas/Address' +""" + result = parse_openapi(openapi_spec) + + assert len(result) == 2 + + person = next(m for m in result if m["name"] == "Person") + address_attr = next(a for a in person["attrs"] if a["name"] == "address") + assert address_attr["type"] == "Address" + + +def test_openapi_array_type(): + """Test parsing OpenAPI schema with array types.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + Order: + type: object + properties: + items: + type: array + items: + type: string + quantities: + type: array + items: + type: integer +""" + result = parse_openapi(openapi_spec) + + order = result[0] + items_attr = next(a for a in order["attrs"] if a["name"] == "items") + assert items_attr["type"] == "List[str]" + + quantities_attr = next(a for a in order["attrs"] if a["name"] == "quantities") + assert quantities_attr["type"] == "List[int]" + + +def test_openapi_with_enum(): + """Test parsing OpenAPI schema with enum.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + Status: + type: object + properties: + status: + type: string + enum: + - pending + - active + - completed +""" + result = parse_openapi(openapi_spec) + + status = result[0] + status_attr = next(a for a in status["attrs"] if a["name"] == "status") + assert status_attr["properties"]["enum"] == ["pending", "active", "completed"] + + +def test_openapi_with_allof(): + """Test parsing OpenAPI schema with allOf (inheritance).""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + BaseModel: + type: object + properties: + id: + type: integer + ExtendedModel: + allOf: + - $ref: '#/components/schemas/BaseModel' + - type: object + properties: + name: + type: string +""" + result = parse_openapi(openapi_spec) + + extended = next(m for m in result if m["name"] == "ExtendedModel") + assert "BaseModel" in extended["parents"] + assert any(a["name"] == "name" for a in extended["attrs"]) + + +def test_openapi_with_formats(): + """Test parsing OpenAPI schema with various formats.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + Document: + type: object + properties: + created_at: + type: string + format: date-time + date: + type: string + format: date + uuid: + type: string + format: uuid + data: + type: string + format: binary +""" + result = parse_openapi(openapi_spec) + + doc = result[0] + + created_attr = next(a for a in doc["attrs"] if a["name"] == "created_at") + assert created_attr["type"] == "datetime.datetime" + + date_attr = next(a for a in doc["attrs"] if a["name"] == "date") + assert date_attr["type"] == "datetime.date" + + uuid_attr = next(a for a in doc["attrs"] if a["name"] == "uuid") + assert uuid_attr["type"] == "uuid.UUID" + + data_attr = next(a for a in doc["attrs"] if a["name"] == "data") + assert data_attr["type"] == "bytes" + + +def test_openapi_with_defaults(): + """Test parsing OpenAPI schema with default values.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + Config: + type: object + properties: + enabled: + type: boolean + default: true + count: + type: integer + default: 10 + name: + type: string + default: "default_name" +""" + result = parse_openapi(openapi_spec) + + config = result[0] + + enabled_attr = next(a for a in config["attrs"] if a["name"] == "enabled") + assert enabled_attr["default"] is True + + count_attr = next(a for a in config["attrs"] if a["name"] == "count") + assert count_attr["default"] == 10 + + name_attr = next(a for a in config["attrs"] if a["name"] == "name") + assert name_attr["default"] == "default_name" + + +def test_openapi_with_constraints(): + """Test parsing OpenAPI schema with validation constraints.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + Validated: + type: object + properties: + age: + type: integer + minimum: 0 + maximum: 150 + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + pattern: "^[a-z]+@[a-z]+[.][a-z]+$" +""" + result = parse_openapi(openapi_spec) + + validated = result[0] + + age_attr = next(a for a in validated["attrs"] if a["name"] == "age") + assert age_attr["properties"]["minimum"] == 0 + assert age_attr["properties"]["maximum"] == 150 + + name_attr = next(a for a in validated["attrs"] if a["name"] == "name") + assert name_attr["properties"]["min_length"] == 1 + assert name_attr["properties"]["max_length"] == 100 + + email_attr = next(a for a in validated["attrs"] if a["name"] == "email") + assert "pattern" in email_attr["properties"] + + +def test_openapi_with_nullable(): + """Test parsing OpenAPI schema with nullable fields.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + NullableModel: + type: object + properties: + optional_field: + type: string + nullable: true + required_field: + type: string +""" + result = parse_openapi(openapi_spec) + + model = result[0] + + optional_attr = next( + a for a in model["attrs"] if a["name"] == "optional_field" + ) + assert optional_attr["properties"]["nullable"] is True + + +def test_openapi_swagger_v2_definitions(): + """Test parsing Swagger 2.0 style definitions.""" + swagger_spec = """ +swagger: "2.0" +info: + title: Test API + version: "1.0" +definitions: + LegacyModel: + type: object + properties: + id: + type: integer + name: + type: string +""" + result = parse_openapi(swagger_spec) + + assert len(result) == 1 + assert result[0]["name"] == "LegacyModel" + + +def test_openapi_multiple_schemas(): + """Test parsing OpenAPI with multiple schemas.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + Post: + type: object + properties: + id: + type: integer + title: + type: string + author: + $ref: '#/components/schemas/User' + Comment: + type: object + properties: + id: + type: integer + text: + type: string + post: + $ref: '#/components/schemas/Post' +""" + result = parse_openapi(openapi_spec) + + assert len(result) == 3 + names = [m["name"] for m in result] + assert "User" in names + assert "Post" in names + assert "Comment" in names + + +def test_openapi_with_description(): + """Test parsing OpenAPI schema with descriptions.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + Documented: + type: object + description: A well-documented model + title: Documented Model + properties: + field: + type: string + description: A field with description +""" + result = parse_openapi(openapi_spec) + + model = result[0] + assert model["properties"]["description"] == "A well-documented model" + assert model["properties"]["title"] == "Documented Model" + + field_attr = next(a for a in model["attrs"] if a["name"] == "field") + assert field_attr["properties"]["description"] == "A field with description" + + +def test_openapi_array_with_ref(): + """Test parsing OpenAPI schema with array of references.""" + openapi_spec = """ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +components: + schemas: + Item: + type: object + properties: + name: + type: string + Container: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/Item' +""" + result = parse_openapi(openapi_spec) + + container = next(m for m in result if m["name"] == "Container") + items_attr = next(a for a in container["attrs"] if a["name"] == "items") + assert items_attr["type"] == "List[Item]" + + +def test_invalid_openapi_spec(): + """Test error handling for invalid OpenAPI specification.""" + with pytest.raises(ValueError, match="Invalid OpenAPI specification"): + parse_openapi("not valid yaml or json {{{") diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..720b3be --- /dev/null +++ b/tox.ini @@ -0,0 +1,11 @@ +[tox] +envlist = py39,py310,py311,py312,py313 +isolated_build = True + +[testenv] +deps = + pytest>=7.4 + parsimonious>=0.10.0 + pyyaml>=6.0 +commands = + pytest tests/ -vv