diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 440f225..e6e661e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -7,26 +7,27 @@ name: tests jobs: -# misc: + misc: # name: "Linting configs and git" -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@v4 -# with: -# fetch-depth: 12 -# -# - uses: docker://docker.io/tamasfe/taplo:latest -# name: "Lint TOMLs" -# with: -# args: fmt --check --diff -# -# - uses: actions/setup-python@v5 -# - name: "Install tox" -# run: | -# pip install tox -# - name: "Lint YAMLs" -# run: | -# tox -e lint-yaml + name: "Linting configs" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 12 + + - uses: docker://docker.io/tamasfe/taplo:latest + name: "Lint TOMLs" + with: + args: fmt --check --diff + + - uses: actions/setup-python@v5 + - name: "Install tox" + run: | + pip install tox + - name: "Lint YAMLs" + run: | + tox -e lint-yaml # - name: "Lint git" # run: | # tox -e lint-git diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..27870a4 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,8 @@ +exclude = [ + "**/.venv/**/*.toml", + "**/.tox/**/*.toml", +] + +[formatting] +column_width = 88 +array_auto_collapse = false diff --git a/.travis.yml b/.travis.yml index 7de3f9b..b46d518 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,15 @@ +--- dist: xenial language: python matrix: include: - - python: '3.6' - - python: '3.7' + - python: "3.6" + - python: "3.7" env: TOXENV=py37 - - python: '3.8' + - python: "3.8" env: TOXENV=py37 install: - python -m pip install --upgrade --editable=./ -script: +script: - nosetests test $EXTRA_ARGS - if [ "$TOXENV" = "py37" ]; then nosetests test37; fi diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..d136089 --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,18 @@ +--- +extends: default + +ignore: + - "**/.venv/*" + - "**/.tox/*" + +rules: + comments: + require-starting-space: true + min-spaces-from-content: 1 + comments-indentation: disable + quoted-strings: + quote-type: double + required: false + check-keys: true + line-length: + max: 88 diff --git a/makefile b/makefile index 12e5f02..bcba55f 100644 --- a/makefile +++ b/makefile @@ -26,8 +26,8 @@ clean: pytest: if ! command -v pytest &>/dev/null; then python3 -m pip install --upgrade pytest; fi - pytest test + pytest tests test: if ! command -v nosetests &>/dev/null; then python3 -m pip install --upgrade nose; fi - nosetests test + nosetests tests diff --git a/pyproject.toml b/pyproject.toml index b84bcb4..2433b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,53 +1,53 @@ [project] -name = 'inject' -dynamic = ['version'] -description = 'Python dependency injection framework.' -license = 'Apache-2.0' -readme = 'README.md' -authors = [{ name = 'Ivan Korobkov', email = 'ivan.korobkov@gmail.com' }] -maintainers = [{ name = 'Ivan Korobkov', email = 'ivan.korobkov@gmail.com' }] +name = "inject" +dynamic = ["version"] +description = "Python dependency injection framework." +license = "Apache-2.0" +readme = "README.md" +authors = [{ name = "Ivan Korobkov", email = "ivan.korobkov@gmail.com" }] +maintainers = [{ name = "Ivan Korobkov", email = "ivan.korobkov@gmail.com" }] classifiers = [ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - 'Topic :: Software Development :: Libraries :: Python Modules', + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", ] -requires-python = '>=3.9' +requires-python = ">=3.9" dependencies = [] [project.urls] -homepage = 'https://github.com/ivankorobkov/python-inject' -source = 'https://github.com/ivankorobkov/python-inject' -issues = 'https://github.com/ivankorobkov/python-inject/issues' +homepage = "https://github.com/ivankorobkov/python-inject" +source = "https://github.com/ivankorobkov/python-inject" +issues = "https://github.com/ivankorobkov/python-inject/issues" [dependency-groups] tests = [ - 'pytest', - 'pytest-cov', + "pytest", + "pytest-cov", ] dev = [ - 'ipython', - { include-group = 'tests' }, + "ipython", + { include-group = "tests" }, ] [build-system] -requires = ['hatchling', 'hatch-vcs'] -build-backend = 'hatchling.build' +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" [tool.black] line-length = 120 skip-string-normalization = true -target_version = ['py39', 'py310', 'py311'] +target_version = ["py39", "py310", "py311"] include = '\.pyi?$' exclude = ''' /( @@ -68,22 +68,22 @@ exclude = ''' ''' [tool.hatch.build.hooks.vcs] -version-file = 'src/inject/_version.py' +version-file = "src/inject/_version.py" [tool.hatch.build.targets.wheel] -packages = ['src/inject'] +packages = ["src/inject"] [tool.hatch.build.targets.wheel.shared-data] [tool.hatch.version] -source = 'vcs' +source = "vcs" [tool.isort] case_sensitive = true include_trailing_comma = true line_length = 120 multi_line_output = 3 -profile = 'black' +profile = "black" [tool.mypy] check_untyped_defs = true @@ -95,99 +95,96 @@ disallow_untyped_decorators = true disallow_untyped_defs = true ignore_missing_imports = true no_implicit_optional = true -python_version = '3.11' +python_version = "3.11" warn_redundant_casts = true warn_return_any = true warn_unused_configs = true warn_unused_ignores = true #strict = true # TODO(pyctrl): improve typings and enable strict mode -exclude = ['test', '.venv'] +exclude = ["tests", ".venv"] [tool.pyright] defineConstant = { DEBUG = true } exclude = [] executionEnvironments = [] ignore = [] -include = ['src/inject', 'test'] -pythonPlatform = 'Linux' -pythonVersion = '3.11' +include = ["src/inject", "tests"] +pythonPlatform = "Linux" +pythonVersion = "3.11" reportMissingImports = true reportMissingTypeStubs = false [tool.ruff] ignore = [ - # Allow non-abstract empty methods in abstract base classes - 'B027', - # Allow boolean positional values in function calls, like `dict.get(... True)` - 'FBT003', - # Ignore checks for possible passwords - 'S105', - 'S106', - 'S107', - # Ignore complexity - 'C901', - 'PLR0911', - 'PLR0912', - 'PLR0913', - 'PLR0915', - 'PLC1901', # empty string comparisons - 'PLW2901', # `for` loop variable overwritten - 'SIM114', # Combine `if` branches using logical `or` operator + "B027", # Allow non-abstract empty methods in abstract base classes + "FBT003", # Allow boolean positional values in function calls, like `dict.get(... True)` + # Ignore checks for possible passwords + "S105", + "S106", + "S107", + # Ignore complexity + "C901", + "PLR0911", + "PLR0912", + "PLR0913", + "PLR0915", + "PLC1901", # empty string comparisons + "PLW2901", # `for` loop variable overwritten + "SIM114", # Combine `if` branches using logical `or` operator ] line-length = 120 select = [ - 'A', - 'B', - 'C', - 'DTZ', - 'E', - 'EM', - 'F', - 'FBT', - 'I', - 'ICN', - 'ISC', - 'N', - 'PLC', - 'PLE', - 'PLR', - 'PLW', - 'Q', - 'RUF', - 'S', - 'SIM', - 'T', - 'TID', - 'UP', - 'W', - 'YTT', + "A", + "B", + "C", + "DTZ", + "E", + "EM", + "F", + "FBT", + "I", + "ICN", + "ISC", + "N", + "PLC", + "PLE", + "PLR", + "PLW", + "Q", + "RUF", + "S", + "SIM", + "T", + "TID", + "UP", + "W", + "YTT", ] -target-version = ['py39', 'py310', 'py311'] +target-version = ["py39", "py310", "py311"] unfixable = [ - # Don't touch unused imports - 'F401', + "F401", # Don't touch unused imports ] [tool.ruff.flake8-quotes] -inline-quotes = 'single' +inline-quotes = "single" [tool.ruff.flake8-tidy-imports] -ban-relative-imports = 'all' +ban-relative-imports = "all" [tool.ruff.isort] -known-first-party = ['inject'] +known-first-party = ["inject"] [tool.ruff.per-file-ignores] # Tests can use magic values, assertions, and relative imports -'/**/test_*.py' = ['PLR2004', 'S101', 'TID252'] +"/**/test_*.py" = ["PLR2004", "S101", "TID252"] #### Pytest & Coverage #### [tool.pytest.ini_options] -minversion = '8.4' -addopts = '-vvv -ra --strict-markers --strict-config' -testpaths = ['test'] -#filterwarnings = ['error'] # TODO(pyctrl): handle all warnings later +minversion = "8.4" +addopts = "-vvv -ra --strict-markers --strict-config" +testpaths = ["tests"] +#filterwarnings = ["error"] # TODO(pyctrl): handle all warnings later # Coverage configuration [tool.coverage.run] @@ -195,60 +192,105 @@ branch = true [tool.coverage.report] include_namespace_packages = true # Regexes for lines to exclude from consideration -omit = ['*/.venv/*', '*/.tox/*', '*/.uv-cache/*'] +omit = ["*/.venv/*", "*/.tox/*", "*/.uv-cache/*"] exclude_also = [ # Don't complain if tests don't hit defensive assertion code: - 'raise NotImplementedError', + "raise NotImplementedError", # Don't complain if non-runnable code isn't run: - 'if __name__ == .__main__.:', + "if __name__ == .__main__.:", # Don't complain about abstract methods, they aren't run: - '@(abc\\.)?abstractmethod', + "@(abc\\.)?abstractmethod", - 'if TYPE_CHECKING:', + "if TYPE_CHECKING:", ] # Tox config [tool.tox] -requires = ['tox>=4.23', 'tox-uv>=1.13'] -runner = 'uv-venv-lock-runner' +requires = ["tox>=4.23", "tox-uv>=1.13"] +runner = "uv-venv-lock-runner" skip_missing_interpreters = true env_list = [ - 'py39', - 'py310', - 'py311', - 'py312', - 'py313', -# 'lint-mypy', # TODO(pyctrl): make it green & uncomment - 'coverage', + "py39", + "py310", + "py311", + "py312", + "py313", + "fmt-toml", + #"lint-mypy", # TODO(pyctrl): make it green & uncomment + "lint-toml", + "lint-yaml", + "coverage", +] + +[tool.tox.labels] +fmt = [ + # "fmt-py", + "fmt-toml", +] +lint = [ + #"lint-py", + #"lint-mypy", # TODO(pyctrl): make it green & uncomment + "lint-toml", + "lint-yaml", + #"lint-git", ] # default env [tool.tox.env_run_base] -description = 'Run unit tests with coverage report ({env_name})' +description = "Run unit tests with coverage report ({env_name})" use_develop = true -dependency_groups = ['tests'] -commands = [['pytest', { replace = 'posargs', extend = true }]] +dependency_groups = ["tests"] +commands = [["pytest", { replace = "posargs", extend = true }]] # tox envs [tool.tox.env.lint-mypy] -description = 'Type checking' -deps = ['mypy'] -commands = [['mypy', { replace = 'posargs', default = ['{tox_root}'], extend = true }]] +description = "Type checking" +deps = ["mypy"] +commands = [["mypy", { replace = "posargs", default = ["{tox_root}"], extend = true }]] + +[tool.tox.env.lint-toml] +description = "Lint TOML files" +allowlist_externals = ["taplo"] +skip_install = true +commands = [ + ["taplo", "lint", { replace = "posargs", extend = true }], + ["taplo", "format", "--check", "--diff", { replace = "posargs", extend = true }], +] + +[tool.tox.env.lint-yaml] +description = "Lint YAML files" +deps = ["yamllint"] +skip_install = true +commands = [ + [ + "yamllint", + "--strict", + { replace = "posargs", default = [ + "{tox_root}", + ], extend = true }, + ], +] + +[tool.tox.env.fmt-toml] +description = "Format TOML files" +allowlist_externals = ["taplo"] +skip_install = true +commands = [["taplo", "format", { replace = "posargs", extend = true }]] [tool.tox.env.coverage] -description = 'run coverage' +description = "run coverage" use_develop = true -dependency_groups = ['tests'] +dependency_groups = ["tests"] commands = [ [ - 'pytest', - '--cov=.', - '--cov-branch', - '--cov-report=term-missing:skip-covered', - { replace = 'posargs', default = ['--cov-report=xml:coverage.xml'], extend = true }, + "pytest", + "--cov=.", + "--cov-branch", + "--cov-report=term-missing:skip-covered", + { replace = "posargs", default = ["--cov-report=xml:coverage.xml"], extend = true }, ], ] diff --git a/src/inject/__init__.py b/src/inject/__init__.py index 88db0d1..8517a16 100644 --- a/src/inject/__init__.py +++ b/src/inject/__init__.py @@ -94,6 +94,7 @@ def my_config(binder): else: _HAS_PEP560_SUPPORT = sys.version_info[:3] >= (3, 7, 0) # PEP 560 _RETURN = 'return' +_MISSING = object() if _HAS_PEP604_SUPPORT: from types import UnionType @@ -325,6 +326,10 @@ def __init__(self, cls: Type[T] | Hashable) -> None: doc="Return an attribute injection", ) + def __set_name__(self, owner: Type[T], name: str) -> None: + if self._cls is _MISSING: + self._cls = _unwrap_cls_annotation(owner, name) + class _ParameterInjection(Generic[T]): __slots__ = ('_name', '_cls') @@ -522,13 +527,16 @@ def instance(cls: Binding) -> Injectable: """Inject an instance of a class.""" return get_injector_or_die().get_instance(cls) +@overload +def attr() -> Injectable: ... + @overload def attr(cls: Hashable) -> Injectable: ... @overload def attr(cls: Type[T]) -> T: ... -def attr(cls): +def attr(cls=_MISSING): """Return an attribute injection (descriptor).""" return _AttributeInjection(cls) @@ -653,3 +661,14 @@ def _is_union_type(typ): return (typ is Union or isinstance(typ, _GenericAlias) and typ.__origin__ is Union) return type(typ) is _Union + + +def _unwrap_cls_annotation(cls: Type, attr_name: str): + types = get_type_hints(cls) + try: + attr_type = types[attr_name] + except KeyError: + msg = f"Couldn't find type annotation for {attr_name}" + raise InjectorException(msg) + + return _unwrap_union_arg(attr_type) diff --git a/test/__init__.py b/tests/__init__.py similarity index 100% rename from test/__init__.py rename to tests/__init__.py diff --git a/test/test_attr.py b/tests/test_attr.py similarity index 52% rename from test/test_attr.py rename to tests/test_attr.py index 93cbbe3..5411b30 100644 --- a/test/test_attr.py +++ b/tests/test_attr.py @@ -1,6 +1,6 @@ from dataclasses import dataclass import inject -from test import BaseTestInject +from tests import BaseTestInject class TestInjectAttr(BaseTestInject): @@ -12,19 +12,20 @@ class MyDataClass: class MyClass: field = inject.attr(int) field2: int = inject.attr(int) + auto_typed_field: int = inject.attr() inject.configure(lambda binder: binder.bind(int, 123)) my = MyClass() my_dc = MyDataClass() - value0 = my.field - value1 = my.field - value2 = my_dc.field - value3 = my_dc.field - assert value0 == 123 - assert value1 == 123 - assert value2 == 123 - assert value3 == 123 + assert my.field == 123 + assert my.field == 123 + assert my.field2 == 123 + assert my.field2 == 123 + assert my.auto_typed_field == 123 + assert my.auto_typed_field == 123 + assert my_dc.field == 123 + assert my_dc.field == 123 def test_invalid_attachment_to_dataclass(self): @dataclass @@ -36,6 +37,7 @@ class MyDataClass: def test_class_attr(self): descriptor = inject.attr(int) + auto_descriptor = inject.attr() @dataclass class MyDataClass: @@ -43,14 +45,16 @@ class MyDataClass: class MyClass(object): field = descriptor + field2: int = descriptor + auto_typed_field: int = auto_descriptor inject.configure(lambda binder: binder.bind(int, 123)) - value0 = MyClass.field - value1 = MyClass.field - value2 = MyDataClass.field - value3 = MyDataClass.field - - assert value0 is descriptor - assert value1 is descriptor - assert value2 is descriptor - assert value3 is descriptor + + assert MyClass.field is descriptor + assert MyClass.field is descriptor + assert MyClass.field2 is descriptor + assert MyClass.field2 is descriptor + assert MyClass.auto_typed_field is auto_descriptor + assert MyClass.auto_typed_field is auto_descriptor + assert MyDataClass.field is descriptor + assert MyDataClass.field is descriptor diff --git a/test/test_autoparams.py b/tests/test_autoparams.py similarity index 99% rename from test/test_autoparams.py rename to tests/test_autoparams.py index 6068e33..a3239c9 100644 --- a/test/test_autoparams.py +++ b/tests/test_autoparams.py @@ -1,7 +1,7 @@ import sys from typing import Optional -from test import BaseTestInject +from tests import BaseTestInject import inject diff --git a/test/test_binder.py b/tests/test_binder.py similarity index 100% rename from test/test_binder.py rename to tests/test_binder.py diff --git a/test/test_context_manager.py b/tests/test_context_manager.py similarity index 98% rename from test/test_context_manager.py rename to tests/test_context_manager.py index 652a8ad..058bf52 100644 --- a/test/test_context_manager.py +++ b/tests/test_context_manager.py @@ -1,7 +1,7 @@ import contextlib import inject -from test import BaseTestInject +from tests import BaseTestInject class Destroyable: diff --git a/test/test_functional.py b/tests/test_functional.py similarity index 100% rename from test/test_functional.py rename to tests/test_functional.py diff --git a/test/test_inject_configuration.py b/tests/test_inject_configuration.py similarity index 98% rename from test/test_inject_configuration.py rename to tests/test_inject_configuration.py index 447b8d5..7ea10f1 100644 --- a/test/test_inject_configuration.py +++ b/tests/test_inject_configuration.py @@ -1,6 +1,6 @@ import inject from inject import InjectorException -from test import BaseTestInject +from tests import BaseTestInject class TestInjectConfiguration(BaseTestInject): diff --git a/test/test_injector.py b/tests/test_injector.py similarity index 100% rename from test/test_injector.py rename to tests/test_injector.py diff --git a/test/test_instance.py b/tests/test_instance.py similarity index 87% rename from test/test_instance.py rename to tests/test_instance.py index 27fcb61..fcfb36b 100644 --- a/test/test_instance.py +++ b/tests/test_instance.py @@ -1,5 +1,5 @@ import inject -from test import BaseTestInject +from tests import BaseTestInject class TestInjectInstance(BaseTestInject): diff --git a/test/test_param.py b/tests/test_param.py similarity index 96% rename from test/test_param.py rename to tests/test_param.py index a21bde4..29767a7 100644 --- a/test/test_param.py +++ b/tests/test_param.py @@ -1,5 +1,5 @@ import inject -from test import BaseTestInject +from tests import BaseTestInject import inspect import asyncio diff --git a/test/test_params.py b/tests/test_params.py similarity index 99% rename from test/test_params.py rename to tests/test_params.py index fc2e8dc..b6ff9de 100644 --- a/test/test_params.py +++ b/tests/test_params.py @@ -1,5 +1,5 @@ import inject -from test import BaseTestInject +from tests import BaseTestInject import inspect import asyncio