diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e81e334..8696fca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -72,7 +72,7 @@ jobs: run: ls -Rla ${{steps.download.outputs.download-path}} - name: 🚀 Publish code coverage to Code Climate - uses: paambaati/codeclimate-action@v4.0.0 + uses: paambaati/codeclimate-action@v5.0.0 env: CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c53a40..b914e3c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.1 + rev: v1.5.4 hooks: - id: forbid-tabs - id: remove-tabs - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-added-large-files - id: check-builtin-literals @@ -24,23 +24,23 @@ repos: - id: isort files: \.py$ - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.12.1 hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: [flake8-typing-imports] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.950 + rev: v1.8.0 hooks: - id: mypy files: octodns_netbox args: [--ignore-missing-imports, --pretty] additional_dependencies: [types-requests] - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.9.0 + rev: v2.12.0 hooks: - id: pretty-format-yaml args: [--autofix] diff --git a/octodns_netbox/__init__.py b/octodns_netbox/__init__.py index c96e73c..8f6e419 100644 --- a/octodns_netbox/__init__.py +++ b/octodns_netbox/__init__.py @@ -11,56 +11,83 @@ import sys import typing from ipaddress import ip_interface -from typing import Literal + +if sys.version_info >= (3, 9): + from typing import Annotated, Literal +elif sys.version_info >= (3, 8): + from typing import Literal + + from typing_extensions import Annotated +else: + from typing_extensions import Annotated, Literal import pynetbox import requests from octodns.record import Record, Rr from octodns.source.base import BaseSource from octodns.zone import DuplicateRecordException, SubzoneRecordException, Zone -from pydantic import AnyHttpUrl, BaseModel, Extra, validator +from pydantic import ( + AnyHttpUrl, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + TypeAdapter, + ValidationInfo, + field_validator, +) import octodns_netbox.reversename +Url = Annotated[ + str, + BeforeValidator(lambda value: str(TypeAdapter(AnyHttpUrl).validate_python(value))), +] + class NetboxSourceConfig(BaseModel): + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) + multivalue_ptr: bool = False - SUPPORTS_MULTIVALUE_PTR: bool = multivalue_ptr + SUPPORTS_MULTIVALUE_PTR_: bool = Field( + multivalue_ptr, alias="SUPPORTS_MULTIVALUE_PTR" + ) + SUPPORTS_DYNAMIC_: bool = Field(False, alias="SUPPORTS_DYNAMIC") SUPPORTS_GEO: bool = False - SUPPORTS_DYNAMIC: bool = False SUPPORTS: typing.Set[str] = set(("A", "AAAA", "PTR")) id: str - url: AnyHttpUrl + url: Url token: str field_name: str = "description" populate_tags: typing.List[str] = [] - populate_vrf_id: typing.Optional[typing.Union[int, Literal["null"]]] = None - populate_vrf_name: typing.Optional[str] = None + populate_vrf_id: Annotated[ + typing.Union[int, Literal["null"], None], Field(validate_default=True) + ] = None + populate_vrf_name: Annotated[ + typing.Optional[str], Field(validate_default=True) + ] = None populate_subdomains: bool = True ttl: int = 60 ssl_verify: bool = True log: logging.Logger - @validator("url") - def check_url(cls, v): + @field_validator("url") + def check_url(cls, v) -> str: if re.search("/api/?$", v): v = re.sub("/api/?$", "", v) return v - @validator("populate_vrf_name") - def check_vrf_name(cls, v, values): - if "populate_vrf_id" in values and ( - v is not None and values["populate_vrf_id"] is not None + @field_validator("populate_vrf_name") + def check_vrf_name( + cls, v: typing.Optional[str], info: ValidationInfo + ) -> typing.Optional[str]: + if "populate_vrf_id" in info.data and ( + v is not None and info.data["populate_vrf_id"] is not None ): raise ValueError("Do not set both populate_vrf_id and populate_vrf") return v - class Config: - extra = Extra.allow - underscore_attrs_are_private = True - arbitrary_types_allowed = True - class NetboxSource(BaseSource, NetboxSourceConfig): def __init__(self, id: str, **kwargs): diff --git a/pyproject.toml b/pyproject.toml index bae7288..c646d96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] build-backend = "poetry_dynamic_versioning.backend" -requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] +requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] [tool] @@ -47,17 +47,19 @@ readme = "README.md" version = "0.0.0" [tool.poetry.dependencies] -octodns = {version = "^0.9.20"} -pydantic = "^1.10.2" +octodns = {version = "^1.0.0"} +poetry = "^1.7.1" +pydantic = "^2.0.0" pynetbox = {version = "^7.0.0"} python = ">=3.8,<4.0" -requests = {version = "^2.25.1"} +requests = {version = "^2.31.0"} +typing-extensions = {version = "^4.9.0", python = "<3.9"} [tool.poetry.dev-dependencies] pre-commit = "^3.0.0" -pytest = "^7.1.2" -pytest-cov = "^4.0.0" -requests-mock = "^1.9.3" +pytest = "^7.4.0" +pytest-cov = "^4.1.0" +requests-mock = "^1.11.0" tox = "^4.0.0" [tool.poetry-dynamic-versioning] diff --git a/tests/test_octodns_netbox.py b/tests/test_octodns_netbox.py index 993e8b0..6e11b7d 100644 --- a/tests/test_octodns_netbox.py +++ b/tests/test_octodns_netbox.py @@ -2,7 +2,7 @@ import requests_mock from octodns.record import Record from octodns.zone import Zone -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from octodns_netbox import NetboxSource @@ -99,17 +99,16 @@ class TestNetboxSourceFailSenarios: def test_init_failed_due_to_missing_url(self): with pytest.raises(ValidationError) as excinfo: NetboxSource("test") - assert excinfo.value.errors() == [ - {"loc": ("url",), "msg": "field required", "type": "value_error.missing"}, - {"loc": ("token",), "msg": "field required", "type": "value_error.missing"}, - ] + assert excinfo.value.errors()[0]["loc"] == ("url",) + assert excinfo.value.errors()[0]["type"] == "missing" + assert excinfo.value.errors()[1]["loc"] == ("token",) + assert excinfo.value.errors()[1]["type"] == "missing" def test_init_failed_due_to_missing_token(self): with pytest.raises(ValidationError) as excinfo: NetboxSource("test", url="http://netbox.example.com/") - assert excinfo.value.errors() == [ - {"loc": ("token",), "msg": "field required", "type": "value_error.missing"} - ] + assert excinfo.value.errors()[0]["loc"] == ("token",) + assert excinfo.value.errors()[0]["type"] == "missing" def test_init_maintain_backword_compatibility_for_url(self): source = NetboxSource( @@ -127,39 +126,25 @@ def test_init_failed_due_to_invalid_field_name_type(self): token="testtoken", field_name=["dns_name", "description"], ) - assert excinfo.value.errors() == [ - { - "loc": ("field_name",), - "msg": "str type expected", - "type": "type_error.str", - } - ] + + assert excinfo.value.errors()[0]["loc"] == ("field_name",) + assert excinfo.value.errors()[0]["type"] == "string_type" def test_init_failed_due_to_invalid_ttl_type(self): with pytest.raises(ValidationError) as excinfo: NetboxSource( "test", url="http://netbox.example.com/", token="testtoken", ttl=[10] ) - assert excinfo.value.errors() == [ - { - "loc": ("ttl",), - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] + assert excinfo.value.errors()[0]["loc"] == ("ttl",) + assert excinfo.value.errors()[0]["type"] == "int_type" def test_init_failed_due_to_invalid_ttl_value(self): with pytest.raises(ValidationError) as excinfo: NetboxSource( "test", url="http://netbox.example.com/", token="testtoken", ttl="ten" ) - assert excinfo.value.errors() == [ - { - "loc": ("ttl",), - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] + assert excinfo.value.errors()[0]["loc"] == ("ttl",) + assert excinfo.value.errors()[0]["type"] == "int_parsing" def test_init_failed_due_to_invalid_populate_tags_type(self): with pytest.raises(ValidationError) as excinfo: @@ -169,13 +154,8 @@ def test_init_failed_due_to_invalid_populate_tags_type(self): token="testtoken", populate_tags="tag", ) - assert excinfo.value.errors() == [ - { - "loc": ("populate_tags",), - "msg": "value is not a valid list", - "type": "type_error.list", - } - ] + assert excinfo.value.errors()[0]["loc"] == ("populate_tags",) + assert excinfo.value.errors()[0]["type"] == "list_type" def test_init_failed_due_to_invalid_populate_vrf_id_type(self): with pytest.raises(ValidationError) as excinfo: @@ -185,18 +165,13 @@ def test_init_failed_due_to_invalid_populate_vrf_id_type(self): token="testtoken", populate_vrf_id=[10], ) - assert excinfo.value.errors() == [ - { - "loc": ("populate_vrf_id",), - "msg": "value is not a valid integer", - "type": "type_error.integer", - }, - { - "loc": ("populate_vrf_id",), - "msg": "unhashable type: 'list'", - "type": "type_error", - }, - ] + assert excinfo.value.errors()[0]["loc"] == ("populate_vrf_id", "int") + assert excinfo.value.errors()[0]["type"] == "int_type" + assert excinfo.value.errors()[1]["loc"] == ( + "populate_vrf_id", + "literal['null']", + ) + assert excinfo.value.errors()[1]["type"] == "literal_error" def test_init_failed_due_to_invalid_populate_vrf_id_value(self): with pytest.raises(ValidationError) as excinfo: @@ -206,19 +181,13 @@ def test_init_failed_due_to_invalid_populate_vrf_id_value(self): token="testtoken", populate_vrf_id="ten", ) - assert excinfo.value.errors() == [ - { - "loc": ("populate_vrf_id",), - "msg": "value is not a valid integer", - "type": "type_error.integer", - }, - { - "ctx": {"given": "ten", "permitted": ("null",)}, - "loc": ("populate_vrf_id",), - "msg": "unexpected value; permitted: 'null'", - "type": "value_error.const", - }, - ] + assert excinfo.value.errors()[0]["loc"] == ("populate_vrf_id", "int") + assert excinfo.value.errors()[0]["type"] == "int_parsing" + assert excinfo.value.errors()[1]["loc"] == ( + "populate_vrf_id", + "literal['null']", + ) + assert excinfo.value.errors()[1]["type"] == "literal_error" def test_init_failed_because_both_populate_vrf_id_populate_vrf_name_are_provided( self, @@ -241,13 +210,8 @@ def test_init_failed_due_to_invalid_populate_vrf_name_type(self): token="testtoken", populate_vrf_name=["TEST"], ) - assert excinfo.value.errors() == [ - { - "loc": ("populate_vrf_name",), - "msg": "str type expected", - "type": "type_error.str", - } - ] + assert excinfo.value.errors()[0]["loc"] == ("populate_vrf_name",) + assert excinfo.value.errors()[0]["type"] == "string_type" def test_init_failed_because_invalid_populate_vrf_name_is_not_found_at_netbox(self): with pytest.raises(ValueError) as excinfo: @@ -267,13 +231,8 @@ def test_init_failed_due_to_invalid_populate_subdomains_type(self): token="testtoken", populate_subdomains="ok", ) - assert excinfo.value.errors() == [ - { - "loc": ("populate_subdomains",), - "msg": "value could not be parsed to a boolean", - "type": "type_error.bool", - } - ] + assert excinfo.value.errors()[0]["loc"] == ("populate_subdomains",) + assert excinfo.value.errors()[0]["type"] == "bool_parsing" class TestNetboxSourcePopulateIPv4PTRNonOctecBoundary: diff --git a/tox.ini b/tox.ini index dc8563d..0232e97 100644 --- a/tox.ini +++ b/tox.ini @@ -12,9 +12,14 @@ python = 3.8: py38, coverage [testenv] +setenv = + PYTHONIOENCODING=utf-8 + PY_COLORS=1 passenv = CI -deps = poetry +allowlist_externals = + poetry commands_pre = + poetry self update poetry self add "poetry-dynamic-versioning[plugin]" poetry run python -m pip install pip -U commands =