diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c8aa3d1d..3f1bcccd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.32.0 +current_version = 0.33.0 commit = True tag = False message = chore: Bump version from {current_version} to {new_version} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ba8d588d..7e476a07 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set Up Python ${{ matrix.python_version }} - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: "${{ matrix.python_version }}" @@ -78,7 +78,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set Up Python ${{ matrix.python_version }} - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: "${{ matrix.python_version }}" @@ -137,7 +137,7 @@ jobs: - name: Store Artifacts if: ${{ always() }} - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: test_reports_${{ matrix.python_version }} path: test-reports/ diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index c234a98e..fe1a05af 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -42,7 +42,7 @@ jobs: - name: Set Up Python id: set_up_python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: "3.10.9" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4842f76f..cb237b21 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -39,7 +39,7 @@ jobs: - name: Set Up Python id: set_up_python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: "3.10.9" @@ -68,7 +68,7 @@ jobs: make dist - name: Store Artifacts - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: release path: ${{ env.ARTIFACTS_PATH }}/ diff --git a/HISTORY.md b/HISTORY.md index 3c83184f..e25c031b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,15 @@ # History +## 0.33.0 (2024-09-24) + +- (PR #689, 2024-09-24) chore(deps): Bump pydantic from 2.7.2 to 2.8.2 +- (PR #694, 2024-09-24) deps: Bump the development-dependencies group with 4 updates +- (PR #695, 2024-09-24) deps: Update `pydantic` from 2.8.2 to 2.9.2 +- (PR #693, 2024-09-24) chore(deps): Bump cryptography from 42.0.8 to 43.0.1 +- (PR #696, 2024-09-24) extras: Add Pydantic type for `Rut` +- (PR #687, 2024-09-24) chore: Bump the production-dependencies group with 2 updates +- (PR #697, 2024-09-24) cte: Close file object in `f29.parse_datos_obj` + ## 0.32.0 (2024-08-28) - (PR #660, 2024-08-23) chore: Bump setuptools from 65.5.1 to 70.3.0 diff --git a/pyproject.toml b/pyproject.toml index c6b22efc..66c5dad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dynamic = ["version"] django = ["Django>=2.2.24"] django-filter = ["django-filter>=24.2"] djangorestframework = ["djangorestframework>=3.10.3,<3.16"] +pydantic = ["pydantic>=2.0"] [project.urls] Homepage = "https://github.com/fyntex/lib-cl-sii-python" diff --git a/requirements-dev.in b/requirements-dev.in index 4a3b3e98..1805d67e 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -5,16 +5,16 @@ -c requirements.txt black==24.8.0 -build==1.0.3 +build==1.2.2 bumpversion==0.5.3 coverage==7.6.1 flake8==7.1.1 isort==5.13.2 -mypy==1.11.1 +mypy==1.11.2 pip-tools==7.4.1 -tox==4.18.0 +tox==4.20.0 twine==5.1.1 types-jsonschema==4.23.0.20240813 types-pyOpenSSL==24.1.0.20240722 -types-pytz==2024.1.0.20240417 +types-pytz==2024.2.0.20240913 wheel==0.44.0 diff --git a/requirements-dev.txt b/requirements-dev.txt index fa0cd541..2f39f580 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ black==24.8.0 # via -r requirements-dev.in bleach==5.0.1 # via readme-renderer -build==1.0.3 +build==1.2.2 # via # -r requirements-dev.in # pip-tools @@ -40,7 +40,7 @@ colorama==0.4.6 # via tox coverage==7.6.1 # via -r requirements-dev.in -cryptography==42.0.8 +cryptography==43.0.1 # via # -c requirements.txt # secretstorage @@ -76,7 +76,7 @@ mccabe==0.7.0 # via flake8 mdurl==0.1.2 # via markdown-it-py -mypy==1.11.1 +mypy==1.11.2 # via -r requirements-dev.in mypy-extensions==1.0.0 # via @@ -153,7 +153,7 @@ tomli==2.0.1 # pyproject-api # pyproject-hooks # tox -tox==4.18.0 +tox==4.20.0 # via -r requirements-dev.in twine==5.1.1 # via -r requirements-dev.in @@ -163,7 +163,7 @@ types-jsonschema==4.23.0.20240813 # via -r requirements-dev.in types-pyopenssl==24.1.0.20240722 # via -r requirements-dev.in -types-pytz==2024.1.0.20240417 +types-pytz==2024.2.0.20240913 # via -r requirements-dev.in types-setuptools==69.5.0.20240415 # via types-cffi diff --git a/requirements.in b/requirements.in index a514d458..664524fd 100644 --- a/requirements.in +++ b/requirements.in @@ -6,7 +6,7 @@ # git+https://github.com/example/example.git@example-vcs-ref#egg=example-pkg[foo,bar]==1.42.3 backports-zoneinfo==0.2.1 ; python_version < "3.9" # Used by `djangorestframework`. -cryptography==42.0.8 +cryptography==43.0.1 defusedxml==0.7.1 django-filter>=24.2 Django>=2.2.24 @@ -15,7 +15,7 @@ importlib-metadata==8.4.0 jsonschema==4.23.0 lxml==5.2.2 marshmallow==3.21.3 -pydantic==2.7.2 +pydantic==2.9.2 pyOpenSSL==24.2.1 pytz==2024.1 signxml==3.2.2 diff --git a/requirements.txt b/requirements.txt index 7996cb6b..ecc55476 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi==2024.7.4 # via signxml cffi==1.16.0 # via cryptography -cryptography==42.0.8 +cryptography==43.0.1 # via # -r requirements.in # pyopenssl @@ -59,9 +59,9 @@ pkgutil-resolve-name==1.3.10 # via jsonschema pycparser==2.22 # via cffi -pydantic==2.7.2 +pydantic==2.9.2 # via -r requirements.in -pydantic-core==2.18.3 +pydantic-core==2.23.4 # via pydantic pyopenssl==24.2.1 # via diff --git a/src/cl_sii/__init__.py b/src/cl_sii/__init__.py index 2826ea58..0b50c67d 100644 --- a/src/cl_sii/__init__.py +++ b/src/cl_sii/__init__.py @@ -4,4 +4,4 @@ """ -__version__ = '0.32.0' +__version__ = '0.33.0' diff --git a/src/cl_sii/cte/f29/parse_datos_obj.py b/src/cl_sii/cte/f29/parse_datos_obj.py index 63af92ca..e6b1d6d0 100644 --- a/src/cl_sii/cte/f29/parse_datos_obj.py +++ b/src/cl_sii/cte/f29/parse_datos_obj.py @@ -28,9 +28,8 @@ _CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES_PATH = ( Path(__file__).parent.parent.parent / 'data' / 'cte' / 'f29_datos_obj_missing_key_fixes.json' ) -CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES: SiiCteF29DatosObjType = json.load( - open(_CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES_PATH) -) +with open(_CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES_PATH) as f: + CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES: SiiCteF29DatosObjType = json.load(f) def parse_sii_cte_f29_datos_obj( diff --git a/src/cl_sii/extras/pydantic_types.py b/src/cl_sii/extras/pydantic_types.py new file mode 100644 index 00000000..a55a2b03 --- /dev/null +++ b/src/cl_sii/extras/pydantic_types.py @@ -0,0 +1,110 @@ +""" +cl_sii "extras" / Pydantic types. +""" + +from __future__ import annotations + +import sys +from typing import Any + + +if sys.version_info[:2] >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +try: + import pydantic + import pydantic.json_schema +except ImportError as exc: # pragma: no cover + raise ImportError("Package 'pydantic' is required to use this module.") from exc + +try: + import pydantic_core +except ImportError as exc: # pragma: no cover + raise ImportError("Package 'pydantic-core' is required to use this module.") from exc + +import cl_sii.rut +import cl_sii.rut.constants + + +class _RutPydanticAnnotation: + """ + `Annotated` wrapper that can be used as the annotation for `cl_sii.rut.Rut` + fields on `pydantic.BaseModels`, `@pydantic.dataclasses`, etc. + + .. seealso:: + - Handling third-party types: + https://docs.pydantic.dev/2.9/concepts/types/#handling-third-party-types + (https://github.com/pydantic/pydantic/blob/v2.9.2/docs/concepts/types.md#handling-third-party-types) + - Customizing the core schema and JSON schema: + https://docs.pydantic.dev/2.9/architecture/#customizing-the-core-schema-and-json-schema + (https://github.com/pydantic/pydantic/blob/v2.9.2/docs/architecture.md#customizing-the-core-schema-and-json-schema) + + Examples: + + >>> from typing import Annotated + >>> import pydantic + >>> import cl_sii.rut + + >>> Rut = Annotated[cl_sii.rut.Rut, _RutPydanticAnnotation] + + >>> class ExampleModel(pydantic.BaseModel): + ... rut: Rut + >>> + >>> example_model_instance = ExampleModel.model_validate({'rut': '78773510-K'}) + + >>> import pydantic.dataclasses + >>> + >>> @pydantic.dataclasses.dataclass + ... class ExampleDataclass: + ... rut: Rut + >>> + >>> example_dataclass_instance = ExampleDataclass(rut='78773510-K') + + >>> example_type_adapter = pydantic.TypeAdapter(Rut) + >>> + >>> example_type_adapter.validate_python('78773510-K') + Rut('78773510-K') + >>> example_type_adapter.validate_json('"78773510-K"') + Rut('78773510-K') + >>> example_type_adapter.dump_python(cl_sii.rut.Rut('78773510-K')) + '78773510-K' + >>> example_type_adapter.dump_json(cl_sii.rut.Rut('78773510-K')) + b'"78773510-K"' + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler + ) -> pydantic_core.core_schema.CoreSchema: + def validate_from_str(value: str) -> cl_sii.rut.Rut: + return cl_sii.rut.Rut(value, validate_dv=False) + + from_str_schema = pydantic_core.core_schema.chain_schema( + [ + pydantic_core.core_schema.str_schema( + pattern=cl_sii.rut.constants.RUT_CANONICAL_STRICT_REGEX.pattern + ), + pydantic_core.core_schema.no_info_plain_validator_function(validate_from_str), + ] + ) + + return pydantic_core.core_schema.json_or_python_schema( + json_schema=from_str_schema, + python_schema=pydantic_core.core_schema.union_schema( + [ + pydantic_core.core_schema.is_instance_schema(cl_sii.rut.Rut), + from_str_schema, + ] + ), + serialization=pydantic_core.core_schema.plain_serializer_function_ser_schema( + lambda instance: instance.canonical + ), + ) + + +Rut = Annotated[cl_sii.rut.Rut, _RutPydanticAnnotation] +""" +Convenience type alias for Pydantic fields that represent Chilean RUTs. +""" diff --git a/src/cl_sii/rut/crypto_utils.py b/src/cl_sii/rut/crypto_utils.py index d9a05069..d8f03c8a 100644 --- a/src/cl_sii/rut/crypto_utils.py +++ b/src/cl_sii/rut/crypto_utils.py @@ -4,6 +4,7 @@ import cryptography import cryptography.x509 from cryptography.hazmat.backends.openssl import backend as crypto_x509_backend +from cryptography.hazmat.primitives.serialization import pkcs12 from . import Rut, constants @@ -21,9 +22,10 @@ def get_subject_rut_from_certificate_pfx(pfx_file_bytes: bytes, password: Option private_key, x509_cert, additional_certs, - ) = crypto_x509_backend.load_key_and_certificates_from_pkcs12( + ) = pkcs12.load_key_and_certificates( data=pfx_file_bytes, password=password.encode() if password is not None else None, + backend=crypto_x509_backend, ) # https://cryptography.io/en/latest/hazmat/primitives/asymmetric/serialization/#cryptography.hazmat.primitives.serialization.pkcs12.load_key_and_certificates # noqa: E501 diff --git a/src/tests/test_extras_pydantic_types.py b/src/tests/test_extras_pydantic_types.py new file mode 100644 index 00000000..e66221d8 --- /dev/null +++ b/src/tests/test_extras_pydantic_types.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +import unittest +from typing import ClassVar + +import pydantic + +from cl_sii.extras.pydantic_types import Rut as PydanticRut +from cl_sii.rut import Rut + + +class PydanticRutTest(unittest.TestCase): + """ + Tests for :class:`PydanticRut`. + """ + + ThirdPartyType: ClassVar[type[Rut]] + PydanticThirdPartyType: ClassVar[type[PydanticRut]] + pydantic_type_adapter: ClassVar[pydantic.TypeAdapter] + valid_instance_1: ClassVar[Rut] + valid_instance_2: ClassVar[Rut] + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.ThirdPartyType = Rut + cls.PydanticThirdPartyType = PydanticRut + cls.pydantic_type_adapter = pydantic.TypeAdapter(cls.PydanticThirdPartyType) + + cls.valid_instance_1 = cls.ThirdPartyType('78773510-K') + assert isinstance(cls.valid_instance_1, cls.ThirdPartyType) + + cls.valid_instance_2 = cls.ThirdPartyType('77004430-8') + assert isinstance(cls.valid_instance_2, cls.ThirdPartyType) + + def test_serialize_to_python(self) -> None: + # -----Arrange----- + + instance = self.valid_instance_1 + expected_serialized_value = '78773510-K' + + # -----Act----- + + actual_serialized_value = self.pydantic_type_adapter.dump_python(instance) + + # -----Assert----- + + self.assertEqual(expected_serialized_value, actual_serialized_value) + + def test_serialize_to_json(self) -> None: + # -----Arrange----- + + instance = self.valid_instance_1 + + expected_serialized_value = b'"78773510-K"' + self.assertEqual( + expected_serialized_value, json.dumps(json.loads(expected_serialized_value)).encode() + ) + + # -----Act----- + + actual_serialized_value = self.pydantic_type_adapter.dump_json(instance) + + # -----Assert----- + + self.assertEqual(expected_serialized_value, actual_serialized_value) + + def test_deserialize_from_instance(self) -> None: + # -----Arrange----- + + obj = self.valid_instance_2 + expected_deserialized_value = self.valid_instance_2 + + # -----Act----- + + actual_deserialized_value = self.pydantic_type_adapter.validate_python(obj) + + # -----Assert----- + + self.assertEqual(expected_deserialized_value, actual_deserialized_value) + + def test_deserialize_from_python(self) -> None: + # -----Arrange----- + + obj = '78773510-K' + expected_deserialized_value = self.valid_instance_1 + + # -----Act----- + + actual_deserialized_value = self.pydantic_type_adapter.validate_python(obj) + + # -----Assert----- + + self.assertEqual(expected_deserialized_value, actual_deserialized_value) + + def test_deserialize_from_json(self) -> None: + # -----Arrange----- + + data = '"78773510-K"' + self.assertEqual(data, json.dumps(json.loads(data))) + + expected_deserialized_value = self.valid_instance_1 + + # -----Act----- + + actual_deserialized_value = self.pydantic_type_adapter.validate_json(data) + + # -----Assert----- + + self.assertEqual(expected_deserialized_value, actual_deserialized_value) + + def test_deserialize_invalid(self) -> None: + test_items = [ + 78773510, + -78773510, + '78773510-k', + '78.773.510-K', + '78773510-X', + '-78773510-K', + True, + None, + ] + + for test_item in test_items: + obj = test_item + data = json.dumps(test_item) + + with self.subTest(item=test_item): + with self.assertRaises(pydantic.ValidationError): + self.pydantic_type_adapter.validate_python(obj) + + with self.assertRaises(pydantic.ValidationError): + self.pydantic_type_adapter.validate_json(data) diff --git a/src/tests/test_libs_crypto_utils.py b/src/tests/test_libs_crypto_utils.py index f30d8b27..d0c18304 100644 --- a/src/tests/test_libs_crypto_utils.py +++ b/src/tests/test_libs_crypto_utils.py @@ -916,7 +916,8 @@ def test_load_der_x509_cert_fail_value_error(self) -> None: with self.assertRaises(ValueError) as cm: load_der_x509_cert(b'hello') self.assertEqual( - cm.exception.args, ("error parsing asn1 value: ParseError { kind: ShortData }",) + cm.exception.args, + ("error parsing asn1 value: ParseError { kind: ShortData { needed: 98 } }",), ) def test_load_pem_x509_cert_ok(self) -> None: @@ -1004,7 +1005,7 @@ def test_load_pem_x509_cert_fail_value_error(self) -> None: ( "Unable to load PEM file. See " "https://cryptography.io/en/latest/faq/#why-can-t-i-import-my-pem-file " - "for more details. InvalidData(InvalidLength)", + "for more details. InvalidData(InvalidLength(5))", ), ) diff --git a/src/tests/test_rut_crypto_utils.py b/src/tests/test_rut_crypto_utils.py index 1e852025..810894cc 100644 --- a/src/tests/test_rut_crypto_utils.py +++ b/src/tests/test_rut_crypto_utils.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch import cryptography.x509 -from cryptography.hazmat.backends.openssl import backend as crypto_x509_backend +from cryptography.hazmat.primitives.serialization import pkcs12 from cl_sii import rut from cl_sii.libs.crypto_utils import load_der_x509_cert @@ -19,8 +19,8 @@ def test_get_subject_rut_from_certificate_pfx_ok(self) -> None: x509_cert = load_der_x509_cert(cert_der_bytes) with patch.object( - crypto_x509_backend, - 'load_key_and_certificates_from_pkcs12', + pkcs12, + 'load_key_and_certificates', Mock(return_value=(None, x509_cert, None)), ): pfx_file_bytes = b'hello' @@ -40,8 +40,8 @@ def test_get_subject_rut_from_certificate_pfx_fails_if_rut_info_is_missing(self) x509_cert = load_der_x509_cert(cert_der_bytes) with patch.object( - crypto_x509_backend, - 'load_key_and_certificates_from_pkcs12', + pkcs12, + 'load_key_and_certificates', Mock(return_value=(None, x509_cert, None)), ): pfx_file_bytes = b'hello' @@ -81,8 +81,8 @@ def test_get_subject_rut_from_certificate_pfx_does_not_throw_attribute_error_if_ ) with patch.object( - crypto_x509_backend, - 'load_key_and_certificates_from_pkcs12', + pkcs12, + 'load_key_and_certificates', Mock(return_value=(None, x509_cert, None)), ), patch.object( x509_cert.extensions,