diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..aa88f0f --- /dev/null +++ b/.flake8 @@ -0,0 +1,17 @@ +# .flake8 +[flake8] + +# E203: Whitespace before ':' +# E501: Line too long (79 characters) +# W503: Line break before binary operator +# W605: Invalid escape sequence +# E305: expected 2 blank lines after class or function definition +# # this comment will cause the warning. +# variable = 1 + +# W605: To use the crimson-templator module +ignore = E501, W605 + +# E305: To add +per-file-ignores = + generate_toml.py: E305, E302 \ No newline at end of file diff --git a/.github/workflows/python-test.yaml b/.github/workflows/python-test.yaml new file mode 100644 index 0000000..e22fb2f --- /dev/null +++ b/.github/workflows/python-test.yaml @@ -0,0 +1,38 @@ +name: Test + +on: + pull_request: + branches: [ main, develop, 'release/*', 'feature/*' ] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 + pip install setuptools wheel + pip install -r requirements.txt + pip install . + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Run tests with coverage + run: | + pytest --cov=src/crimson/auto_pydantic --cov-report=html + diff --git a/.github/release.yaml b/.github/workflows/release.yaml similarity index 90% rename from .github/release.yaml rename to .github/workflows/release.yaml index 14473d8..9ab0fbc 100644 --- a/.github/release.yaml +++ b/.github/workflows/release.yaml @@ -1,11 +1,7 @@ name: Release Workflow on: - push: - branches: - - main - tags: - - 'v*' + workflow_dispatch: jobs: build-and-release: diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c64f511 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + // Folderable .py + + "editor.foldingStrategy": "indentation", + "editor.foldRegions": { + "start": "^\\s*#\\s*region\\b", + "end": "^\\s*#\\s*endregion\\b" + }, + "editor.defaultFoldingImportLevel": 2, + + + // Additional VS Code Settings + "workbench.settings.openDefaultSettings": true, + + // Unittest Setup + "python.testing.pytestEnabled": true, + "python.testing.pytestPath": "test", + "python.testing.pytestArgs": [ + "test" + ], + "python.testing.unittestEnabled": false, +} diff --git a/auto-pydantic-dev b/auto-pydantic-dev new file mode 160000 index 0000000..60c08a3 --- /dev/null +++ b/auto-pydantic-dev @@ -0,0 +1 @@ +Subproject commit 60c08a3be7009d979fb74092dfbc3c1ec94ca64a diff --git a/pyproject.toml b/pyproject.toml index aadf234..3ee6210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,9 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "pydantic", # Assuming core also uses pydantic, adjust as necessary + "pydantic", + "crimson-code-extractor", + "crimson-ast-dev-tool" ] requires-python = ">=3.9" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1538f89 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +inflection +jinja2 +crimson-code-extractor +crimson-ast-dev-tool +crimson-file-loader +pytest +pytest-cov diff --git a/scripts/generate_dev_repo.sh b/scripts/generate_dev_repo.sh new file mode 100644 index 0000000..604cd1d --- /dev/null +++ b/scripts/generate_dev_repo.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Parent repository 이름을 묻기 +echo "Enter the parent repository name:" +read parent_repo + +# 새로운 repository 이름 생성 +dev_repo="${parent_repo}-dev" + +# GitHub CLI를 사용하여 새로운 private repository 생성 +gh repo create "$dev_repo" --private --description "Development repository for $parent_repo" --gitignore Python + +# 결과 확인 +if [ $? -eq 0 ]; then + echo "Successfully created repository: $dev_repo" + + # 새로운 repository를 현재 디렉토리에 clone + git clone "https://github.com/crimson206/$dev_repo.git" + + # clone 결과 확인 + if [ $? -eq 0 ]; then + echo "Successfully cloned repository: $dev_repo" + + # README 파일 생성 및 추가 + cd "$dev_repo" + echo "# $dev_repo" > README.md + git add README.md + git commit -m "Add README file" + git push origin main + echo "Successfully added README file to repository: $dev_repo" + + # parent repository의 디렉토리로 이동 (상위 디렉토리로 이동) + cd .. + + else + echo "Failed to clone repository: $dev_repo" + fi +else + echo "Failed to create repository: $dev_repo" +fi diff --git a/scripts/setup_base.sh b/scripts/setup_base.sh new file mode 100644 index 0000000..760975a --- /dev/null +++ b/scripts/setup_base.sh @@ -0,0 +1 @@ +pip install -r ./scripts/setup_requirements.txt \ No newline at end of file diff --git a/scripts/setup_env.sh b/scripts/setup_env.sh new file mode 100644 index 0000000..b5aa7bc --- /dev/null +++ b/scripts/setup_env.sh @@ -0,0 +1,10 @@ +# !bin/bash + +read -p "Please enter the Python version you want to use (e.g., 3.9): " PYTHON_VERSION + +conda create --name auto-pydantic python=$PYTHON_VERSION -y + +conda activate auto-pydantic + +pip install -r requirements.txt + diff --git a/scripts/setup_requirements.txt b/scripts/setup_requirements.txt new file mode 100644 index 0000000..fe53d34 --- /dev/null +++ b/scripts/setup_requirements.txt @@ -0,0 +1,2 @@ +pydantic +crimson-templator \ No newline at end of file diff --git a/scripts/unittest.sh b/scripts/unittest.sh new file mode 100644 index 0000000..07d7fa8 --- /dev/null +++ b/scripts/unittest.sh @@ -0,0 +1,3 @@ +#!bin/bash + +coverage run --source=. -m unittest discover -s ./test -p "test_*.py" \ No newline at end of file diff --git a/src/crimson/auto_pydantic.py b/src/crimson/auto_pydantic.py deleted file mode 100644 index 8c74ebb..0000000 --- a/src/crimson/auto_pydantic.py +++ /dev/null @@ -1 +0,0 @@ -## empty \ No newline at end of file diff --git a/src/crimson/auto_pydantic/__init__.py b/src/crimson/auto_pydantic/__init__.py new file mode 100644 index 0000000..34bf0d9 --- /dev/null +++ b/src/crimson/auto_pydantic/__init__.py @@ -0,0 +1,2 @@ +from .generator import generate_constructor, generate_output_props, generate_input_props +from .validator import validate diff --git a/src/crimson/auto_pydantic/generator.py b/src/crimson/auto_pydantic/generator.py new file mode 100644 index 0000000..71877cf --- /dev/null +++ b/src/crimson/auto_pydantic/generator.py @@ -0,0 +1,84 @@ +from inflection import camelize +from typing import List +from crimson.code_extractor import extract +from crimson.ast_dev_tool import safe_unparse +import ast + + +def generate_constructor(function_node: ast.FunctionDef, indent: int = 4) -> str: + func_spec = extract.extract_func_spec(function_node) + indent = " " * indent + if func_spec.name == "__init__": + start = "def __init__(" + else: + start = "def __init__(self, " + + def_init = indent + start + safe_unparse(function_node.args) + "):\n" + super_init = indent * 2 + "super().__init__(" + for arg_spec in func_spec.arg_specs: + arg_name = arg_spec.name + if arg_spec.name in ["self", "cls"]: + continue + super_init += f"{arg_name}={arg_name}, " + super_init = super_init[:-2] + ")" + + constructor = def_init + super_init + return constructor + + +def generate_input_props(function_node: ast.FunctionDef, constructor: bool = True): + func_spec = extract.extract_func_spec(function_node) + + input_props_name = _generate_input_props_name(func_spec.name) + input_props_lines: List[str] = [f"class {input_props_name}(BaseModel):"] + arg_line_template = " {arg_name}: {annotation} = {field}" + + for arg_spec in func_spec.arg_specs: + if arg_spec.name in ["self", "cls"]: + continue + + annotation = arg_spec.annotation if arg_spec.annotation is not None else "Any" + + if arg_spec.default is not None: + field = f"Field(default={repr(arg_spec.default)})" + elif arg_spec.type == "vararg": + field = "Field(default=())" + elif arg_spec.type == "kwarg": + field = "Field(default={})" + else: + field = "Field(...)" + + arg_line = arg_line_template.format(arg_name=arg_spec.name, annotation=annotation, field=field) + input_props_lines.append(arg_line) + + input_props = "\n".join(input_props_lines) + + if constructor is True: + constructor = generate_constructor(function_node) + input_props += "\n\n" + constructor + + return input_props + + +def generate_output_props(function_node: ast.FunctionDef): + func_spec = extract.extract_func_spec(function_node) + Func_name = camelize(func_spec.name, uppercase_first_letter=True) + + output_props_lines: List[str] = [f"class {Func_name}OutputProps(BaseModel):"] + arg_line_template = " return: {annotation}" + + annotation = func_spec.return_annotation if func_spec.return_annotation is not None else "Any" + + arg_line = arg_line_template.format( + annotation=annotation, + ) + + output_props_lines.append(arg_line) + output_props = "\n".join(output_props_lines) + return output_props + + +def _generate_input_props_name(func_name: str) -> str: + Func_name = camelize(func_name, uppercase_first_letter=True) + input_props_name = f"{Func_name}InputProps" + return input_props_name diff --git a/src/crimson/auto_pydantic/validator.py b/src/crimson/auto_pydantic/validator.py new file mode 100644 index 0000000..d9474ea --- /dev/null +++ b/src/crimson/auto_pydantic/validator.py @@ -0,0 +1,59 @@ +from typing import Callable, Dict, Any +import ast +from crimson.ast_dev_tool import get_first_node +from crimson.auto_pydantic.generator import generate_input_props, _generate_input_props_name +from inspect import getsource +import typing +import threading + +_data_classes_lock = threading.Lock() +_data_classes = {} + + +def validate(func: Callable, currentframe, *args, **kwargs): + namespace = _prepare_namespace(currentframe, args, kwargs) + function_node = _get_function_node(func) + func_name = _generate_input_props_name(func.__name__) + + InputProps = _get_or_create_input_props(func, function_node, func_name, namespace) + namespace[func_name] = InputProps + + _execute_validation(func_name, namespace) + + +def _prepare_namespace(currentframe, args, kwargs) -> Dict[str, Any]: + namespace = {} + namespace.update(currentframe.f_globals.copy()) + namespace.update(_get_types()) + namespace.update({"args": args, "kwargs": kwargs}) + return namespace + + +def _get_function_node(func: Callable) -> ast.FunctionDef: + func_source = getsource(func) + return get_first_node(func_source, ast.FunctionDef) + + +def _get_or_create_input_props( + func: Callable, function_node: ast.FunctionDef, func_name: str, namespace: Dict[str, Any] +): + with _data_classes_lock: + if func not in _data_classes: + _data_classes[func] = _create_input_props(function_node, func_name, namespace) + return _data_classes[func] + + +def _create_input_props(function_node: ast.FunctionDef, func_name: str, namespace: Dict[str, Any]): + model = generate_input_props(function_node) + local_scope = {} + exec(model, namespace, local_scope) + return local_scope[func_name] + + +def _execute_validation(func_name: str, namespace: Dict[str, Any]): + validation = f"\n{func_name}(*args, **kwargs)" + exec(validation, namespace) + + +def _get_types() -> Dict[str, Any]: + return {"Any": typing.Any} diff --git a/template b/template new file mode 160000 index 0000000..18db60d --- /dev/null +++ b/template @@ -0,0 +1 @@ +Subproject commit 18db60d0947ffce5dd3d8601d89c18b0fc98ac2d diff --git a/test/test_generator.py b/test/test_generator.py new file mode 100644 index 0000000..0a73f7d --- /dev/null +++ b/test/test_generator.py @@ -0,0 +1,85 @@ +import pytest +import ast +from crimson.auto_pydantic.generator import generate_input_props, generate_output_props, generate_constructor + + +def create_function_node(func_str): + return ast.parse(func_str).body[0] + + +@pytest.fixture +def simple_function(): + return create_function_node( + """ +def simple_func(arg1: int, arg2: str = "default") -> str: + return f"{arg1} {arg2}" +""" + ) + + +@pytest.fixture +def complex_function(): + return create_function_node( + """ +def complex_func( + arg1: int, + *args: tuple, + kwarg1: str = "default", + **kwargs +) -> dict: + return {} +""" + ) + + +def test_generate_input_props_simple(simple_function): + result = generate_input_props(simple_function) + expected = """\ +class SimpleFuncInputProps(BaseModel): + arg1: int = Field(...) + arg2: str = Field(default="'default'") + + def __init__(self, arg1: int, arg2: str='default'): + super().__init__(arg1=arg1, arg2=arg2)""" + assert result.strip() == expected.strip() + + +def test_generate_input_props_complex(complex_function): + result = generate_input_props(complex_function) + expected = r"""class ComplexFuncInputProps(BaseModel): + arg1: int = Field(...) + args: tuple = Field(default=()) + kwarg1: str = Field(default="'default'") + kwargs: Any = Field(default={}) + + def __init__(self, arg1: int, *args: tuple, kwarg1: str='default', **kwargs): + super().__init__(arg1=arg1, args=args, kwarg1=kwarg1, kwargs=kwargs)""" + assert result.strip() == expected.strip() + + +def test_generate_output_props_simple(simple_function): + result = generate_output_props(simple_function) + expected = """class SimpleFuncOutputProps(BaseModel): + return: str""" + assert result.strip() == expected.strip() + + +def test_generate_output_props_complex(complex_function): + result = generate_output_props(complex_function) + expected = """class ComplexFuncOutputProps(BaseModel): + return: dict""" + assert result.strip() == expected.strip() + + +def test_generate_constructor_simple(simple_function): + result = generate_constructor(simple_function) + expected = r"""def __init__(self, arg1: int, arg2: str='default'): + super().__init__(arg1=arg1, arg2=arg2)""" + assert result.strip() == expected.strip() + + +def test_generate_constructor_complex(complex_function): + result = generate_constructor(complex_function) + expected = r""" def __init__(self, arg1: int, *args: tuple, kwarg1: str='default', **kwargs): + super().__init__(arg1=arg1, args=args, kwarg1=kwarg1, kwargs=kwargs)""" + assert result.strip() == expected.strip() diff --git a/test/test_validator.py b/test/test_validator.py new file mode 100644 index 0000000..94d4c82 --- /dev/null +++ b/test/test_validator.py @@ -0,0 +1,65 @@ +import pytest +from pydantic import ValidationError +from typing import List, Optional +from crimson.auto_pydantic.validator import validate +from pydantic import BaseModel, Field +from inspect import currentframe + + +def simple_function(arg1: int, arg2: str = "default") -> str: + return f"{arg1} {arg2}" + + +def complex_function(arg1: int, arg2: int = 1, *args: tuple, kwarg1: str = "default", **kwargs) -> dict: + return {} + + +def test_validate_simple_valid(): + # This should not raise any exception + validate(simple_function, currentframe(), arg1=1, arg2="test") + + +def test_validate_simple_invalid_type(): + with pytest.raises(Exception): + validate(simple_function, currentframe(), "not an int", "test") + + +def test_validate_simple_missing_required(): + with pytest.raises(Exception): # We're just checking if any exception is raised + validate(simple_function) + + +def test_validate_complex_valid(): + + # This should not raise any exception + validate(complex_function, currentframe(), 1, 2, 3, kwarg1="test", extra="stuff") + + +def test_validate_complex_invalid_type(): + with pytest.raises(Exception): + validate(complex_function, currentframe(), "not an int") + + +def test_validate_complex_extra_args(): + # This should not raise any exception, as extra args are allowed + validate(complex_function, currentframe(), 1, 2, 3, 4, 5, kwarg1="test", extra="stuff") + + +def test_validate_with_default(): + # This should not raise any exception, as arg2 has a default value + validate(simple_function, currentframe(), 1) + + +def test_validate_override_default(): + # This should not raise any exception + validate(simple_function, currentframe(), 1, "override") + + +def test_validate_kwargs(): + # This should not raise any exception + validate(complex_function, currentframe(), arg1=1, kwarg1="test", extra="stuff") + + +def test_validate_mixed_args_kwargs(): + # This should not raise any exception + validate(complex_function, currentframe(), 1, 2, 3, kwarg1="test", extra="stuff")