diff --git a/.gitignore b/.gitignore index b250aa7..c77977c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ pip-wheel-metadata # Virtualenv +.venv/ /env/ /src/ @@ -21,3 +22,7 @@ htmlcov # Sphinx documentation /doc/build/ +/doc/_build/ + +# Type checkers +.mypy_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f64684f..2c60082 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: rev: "7.3.0" hooks: - id: flake8 - additional_dependencies: [flake8-pyproject] + additional_dependencies: [flake8-pyproject, flake8-type-checking] - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: diff --git a/CHANGES.txt b/CHANGES.txt index 33288e0..bf07847 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,26 +1,30 @@ CHANGES ******* -0.15 (unreleased) -================= +1.0.0 (unreleased) +================== - Add type hints - Add partial attribute to directive methods for better typing support -- Fix Flake8 errors. - -- Apply Black code formatter. +- Fix linting errors. - Add support for Python 3.10, 3.11, 3.12, 3.13 and 3.14 - Drop support for Python 3.9 and below - Use GitHub Actions instead of Travis for CI. -(Travis no longer gives free credits to open source projects.) + (Travis no longer gives free credits to open source projects.) - Cleanup old Python 2 code with pyupgrade_. +- Add tests for 100% coverage. + +- Update docs. + +- Update scenarios to Dectate 0.12+ API and add type annotations. + .. _pyupgrade: https://github.com/asottile/pyupgrade diff --git a/CREDITS.txt b/CREDITS.txt index e697e66..df71f5d 100644 --- a/CREDITS.txt +++ b/CREDITS.txt @@ -8,7 +8,7 @@ CREDITS * Denis Krienbühl (testing and feedback) -* Henri Hulski (build environment) +* Henri Hulski (build environment and maintainer) * Jan Stürtz diff --git a/README.rst b/README.rst index 39b3e86..26fc84e 100644 --- a/README.rst +++ b/README.rst @@ -19,8 +19,8 @@ Dectate is a powerful configuration engine for Python frameworks. `Read the docs`_ -.. _`Read the docs`: http://dectate.readthedocs.org +.. _`Read the docs`: https://dectate.readthedocs.io It is used by Morepath_. -.. _Morepath: http://morepath.readthedocs.org +.. _Morepath: https://morepath.readthedocs.io diff --git a/dectate/__init__.py b/dectate/__init__.py index f576588..ce9fb57 100644 --- a/dectate/__init__.py +++ b/dectate/__init__.py @@ -1,16 +1,16 @@ from .app import App, directive -from .sentinel import Sentinel, NOT_FOUND -from .config import commit, Action, Composite, CodeInfo +from .config import Action, CodeInfo, Composite, commit from .error import ( ConfigError, + ConflictError, DirectiveError, - TopologicalSortError, DirectiveReportError, - ConflictError, QueryError, + TopologicalSortError, ) from .query import Query -from .tool import query_tool, convert_dotted_name, convert_bool, query_app +from .sentinel import NOT_FOUND, Sentinel +from .tool import convert_bool, convert_dotted_name, query_app, query_tool from .toposort import topological_sort __all__ = ( diff --git a/dectate/app.py b/dectate/app.py index 773cea0..9c19bc1 100644 --- a/dectate/app.py +++ b/dectate/app.py @@ -11,15 +11,16 @@ TypeVar, cast, ) + from .config import Configurable, Directive, commit, create_code_info if TYPE_CHECKING: from collections.abc import Callable, Collection, Iterator from typing_extensions import Self + from .config import Action, Composite, DirectiveAbbreviation from .types import DirectiveCallable -_T = TypeVar("_T") _ActionT = TypeVar("_ActionT", bound="Action | Composite") _AppT = TypeVar("_AppT", bound="App") _P = ParamSpec("_P") @@ -173,7 +174,7 @@ class DirectiveMethod(Generic[_AppT, _P]): __name__: str __qualname__: str - def __init__(self, func: DirectiveCallable[Concatenate[Any, _P]]): + def __init__(self, func: DirectiveCallable[Concatenate[Any, _P]]) -> None: self.__func__ = func update_wrapper(self, func) # type: ignore[arg-type] @@ -197,8 +198,8 @@ def directive( :class:`dectate.Composite` subclass and can attach the result as a class method to an :class:`dectate.App` subclass:: - class FooAction(dectate.Action): - ... + class FooAction(dectate.Action): ... + class MyApp(dectate.App): my_directive = dectate.directive(MyAction) @@ -208,16 +209,14 @@ class MyApp(dectate.App): class MyApp(dectate.App): @directive - class my_directive(dectate.Action): - ... + class my_directive(dectate.Action): ... :param action_factory: an action class to use as the directive. :return: a class method that represents the directive. """ if not isinstance(action_factory, type): - raise TypeError( - "action_factory needs to be `dectate.Action` or `dectate.Composite` subclass." - ) + msg = "action_factory needs to be `dectate.Action` or `dectate.Composite` subclass." + raise TypeError(msg) def method(cls: Any, *args: _P.args, **kw: _P.kwargs) -> Directive: frame = sys._getframe(2) diff --git a/dectate/config.py b/dectate/config.py index 168d3b5..6e5bef2 100644 --- a/dectate/config.py +++ b/dectate/config.py @@ -1,22 +1,24 @@ from __future__ import annotations import abc +import inspect import logging import sys -import inspect from typing import TYPE_CHECKING, Any, ClassVar, TypeVar + from .error import ( - ConflictError, ConfigError, + ConflictError, DirectiveError, DirectiveReportError, ) -from .toposort import topological_sort from .sentinel import NOT_FOUND +from .toposort import topological_sort if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator from types import FrameType, TracebackType + from .app import App, Config from .sentinel import Sentinel @@ -45,14 +47,13 @@ class Configurable: app_class: type[App] | None = None def __init__(self, extends: list[Configurable], config: Config) -> None: - """ - :param extends: - the configurables that this configurable extends. - :type extends: list of configurables. - :param config: - the object that will contains the actual configuration. - Normally it's the ``config`` class attribute of the - :class:`dectate.App` subclass. + """:param extends: + the configurables that this configurable extends. + :type extends: list of configurables. + :param config: + the object that will contains the actual configuration. + Normally it's the ``config`` class attribute of the + :class:`dectate.App` subclass. """ self.extends = extends self.config = config @@ -95,7 +96,7 @@ def get_action_classes(self) -> dict[type[Action | Composite], str]: :return: a dict with action class keys and name values. """ - result = {} + result: dict[type[Action | Composite], str] = {} app_class = self.app_class assert app_class is not None for name, method in app_class.get_directive_methods(): @@ -158,15 +159,11 @@ def setup_config(self, action_class: type[Action]) -> None: configured = getattr(config, name, None) if configured is not None: if seen[name] is not factory: - raise ConfigError( - "Inconsistent factories for config %r (%r and %r)" - % ((name, seen[name], factory)) - ) + msg = f"Inconsistent factories for config {name!r} ({seen[name]!r} and {factory!r})" + raise ConfigError(msg) continue seen[name] = factory - kw = get_factory_arguments( - action_class, config, factory, self.app_class - ) + kw = get_factory_arguments(action_class, config, factory, self.app_class) setattr(config, name, factory(**kw)) def delete_config(self, action_class: type[Action]) -> None: @@ -181,16 +178,14 @@ def delete_config(self, action_class: type[Action]) -> None: factory_arguments = getattr(factory, "factory_arguments", None) if factory_arguments is None: continue - for name in factory_arguments.keys(): + for name in factory_arguments: if hasattr(config, name): delattr(config, name) def group_actions(self) -> None: """Groups actions for this configurable into action groups.""" # turn directives into actions - actions = [ - (directive.action(), obj) for (directive, obj) in self._directives - ] + actions = [(directive.action(), obj) for (directive, obj) in self._directives] # add the actions for this configurable to the action group d = self._action_groups @@ -201,9 +196,7 @@ def group_actions(self) -> None: action_class = action.__class__ d[action_class].add(action, obj) - def get_action_group( - self, action_class: type[Action] - ) -> ActionGroup | None: + def get_action_group(self, action_class: type[Action]) -> ActionGroup | None: """Return ActionGroup for ``action_class`` or ``None`` if not found. :param action_class: the action class to find the action group of. @@ -219,9 +212,7 @@ def action_extends(self, action_class: type[Action]) -> list[ActionGroup]: extends. """ return [ - configurable._action_groups.get( - action_class, ActionGroup(action_class, []) - ) + configurable._action_groups.get(action_class, ActionGroup(action_class, [])) for configurable in self.extends ] @@ -245,11 +236,8 @@ class ActionGroup: indicate another action class to group with using ``group_class``. """ - def __init__( - self, action_class: type[Action], extends: list[ActionGroup] - ) -> None: - """ - :param action_class: + def __init__(self, action_class: type[Action], extends: list[ActionGroup]) -> None: + """:param action_class: the action_class that identifies this action group. :param extends: list of action groups extended by this action group. @@ -337,7 +325,8 @@ def execute(self, configurable: Configurable) -> None: action._log(configurable, obj) action.perform(obj, **kw) except DirectiveError as e: - raise DirectiveReportError(f"{e}", action.code_info) + msg = f"{e}" + raise DirectiveReportError(msg, action.code_info) # run the group class after operation self.action_class.after(**kw) @@ -505,7 +494,7 @@ def _log(self, configurable: Configurable, obj: Any) -> None: self.directive.log(configurable, obj) def get_value_for_filter(self, name: str) -> Any | Sentinel: - """Get value. Takes into account ``filter_name``, ``filter_get_value`` + """Get value. Takes into account ``filter_name``, ``filter_get_value``. Used by the query system. You can override it if your action has a different way storing values altogether. @@ -517,8 +506,8 @@ def get_value_for_filter(self, name: str) -> Any | Sentinel: value = getattr(self, actual_name, NOT_FOUND) if value is not NOT_FOUND: return value - if self.filter_get_value is None: - return value # type: ignore[unreachable] + if self.filter_get_value is None: # pragma: no cover + return value # type: ignore[unreachable] # pragma: no cover return self.filter_get_value(name) @classmethod @@ -532,7 +521,7 @@ def _get_config_kw(cls, configurable: Configurable) -> dict[str, Any]: dict for. :return: a dict of config values. """ - result = {} + result: dict[str, Any] = {} config = configurable.config group_class = cls.group_class if group_class is None: @@ -541,7 +530,7 @@ def _get_config_kw(cls, configurable: Configurable) -> dict[str, Any]: if group_class.app_class_arg: result["app_class"] = configurable.app_class # add the config items themselves - for name, factory in group_class.config.items(): + for name, _factory in group_class.config.items(): result[name] = getattr(config, name) return result @@ -729,8 +718,7 @@ def __init__( args: tuple[Any, ...], kw: dict[str, Any], ) -> None: - """ - :param action_factory: function that constructs an action instance. + """:param action_factory: function that constructs an action instance. :code_info: a :class:`CodeInfo` instance describing where this directive was invoked. :param app_class: the :class:`dectate.App` subclass that this @@ -758,7 +746,8 @@ def action(self) -> Action | Composite: try: result = self.action_factory(*self.args, **self.kw) except TypeError as e: - raise DirectiveReportError(f"{e}", self.code_info) + msg = f"{e}" + raise DirectiveReportError(msg, self.code_info) # store the directive used on the action, useful for error reporting result.directive = self @@ -820,15 +809,12 @@ def log(self, configurable: Configurable, obj: Any) -> None: [f"{key}={value!r}" for key, value in sorted(kw.items())] ) - message = "@{}.{}({}) on {}".format( - target_dotted_name, - directive_name, - arguments, - func_dotted_name, + message = ( + f"@{target_dotted_name}.{directive_name}({arguments}) on {func_dotted_name}" ) if not is_same: - message += " (from %s)" % dotted_name(self.app_class) + message += f" (from {dotted_name(self.app_class)})" logger.debug(message) @@ -869,7 +855,7 @@ def __exit__( def commit(*apps: type[App] | Configurable) -> None: - """Commit one or more app classes + """Commit one or more app classes. A commit causes the configuration actions to be performed. The resulting configuration information is stored under the @@ -882,7 +868,7 @@ def commit(*apps: type[App] | Configurable) -> None: :param `*apps`: one or more :class:`App` subclasses to perform configuration actions on. """ - configurables = [] + configurables: list[Configurable] = [] for c in apps: if isinstance(c, Configurable): configurables.append(c) @@ -925,7 +911,7 @@ def group_action_classes( :return: set of action classes grouped together. """ # we want to have use group_class for each true Action class - result = set() + result: set[type[Action]] = set() for action_class in action_classes: if not issubclass(action_class, Action): continue @@ -934,25 +920,29 @@ def group_action_classes( group_class = action_class else: if group_class.group_class is not None: - raise ConfigError( + msg = ( "Cannot use group_class on another action class " - "that uses group_class: %r" % action_class + f"that uses group_class: {action_class!r}" ) + raise ConfigError(msg) if "config" in action_class.__dict__: - raise ConfigError( + msg = ( "Cannot use config class attribute when you use " - "group_class: %r" % action_class + f"group_class: {action_class!r}" ) + raise ConfigError(msg) if "before" in action_class.__dict__: - raise ConfigError( + msg = ( "Cannot define before method when you use " - "group_class: %r" % action_class + f"group_class: {action_class!r}" ) + raise ConfigError(msg) if "after" in action_class.__dict__: - raise ConfigError( + msg = ( "Cannot define after method when you use " - "group_class: %r" % action_class + f"group_class: {action_class!r}" ) + raise ConfigError(msg) result.add(group_class) return result @@ -974,12 +964,13 @@ def expand_actions( # make sure all sub actions propagate originating directive # info try: - sub_actions = [] + sub_actions: list[tuple[Action | Composite, Any]] = [] for sub_action, sub_obj in action.actions(obj): sub_action.directive = action.directive sub_actions.append((sub_action, sub_obj)) except DirectiveError as e: - raise DirectiveReportError(f"{e}", action.code_info) + msg = f"{e}" + raise DirectiveReportError(msg, action.code_info) yield from expand_actions(sub_actions) else: if not hasattr(action, "order"): @@ -1034,7 +1025,7 @@ def factory_key(item: tuple[str, _F]) -> Iterable[tuple[str, _F]]: :return: iterable of ``name, factory`` tuples that factory in item depends on for construction. """ - name, factory = item + _name, factory = item arguments = getattr(factory, "factory_arguments", None) if arguments is None: return [] @@ -1072,16 +1063,14 @@ def get_factory_arguments( if arguments is None: return result - for name in arguments.keys(): + for name in arguments: value = getattr(config, name, None) if value is None: - raise ConfigError( - ( - "Cannot find factory argument %r for " - "factory %r in action class %r" - ) - % (name, factory, action_class) + msg = ( + f"Cannot find factory argument {name!r} for factory {factory!r} " + f"in action class {action_class!r}" ) + raise ConfigError(msg) result[name] = getattr(config, name, None) return result diff --git a/dectate/error.py b/dectate/error.py index 2ee1196..32e9e4e 100644 --- a/dectate/error.py +++ b/dectate/error.py @@ -31,8 +31,8 @@ def __init__(self, actions: list[Action]) -> None: code_info = action.code_info if code_info is None: continue - result.append(" %s" % code_info.filelineno()) - result.append(" %s" % code_info.sourceline) + result.append(f" {code_info.filelineno()}") + result.append(f" {code_info.sourceline}") msg = "\n".join(result) super().__init__(msg) @@ -46,8 +46,8 @@ class DirectiveReportError(ConfigError): def __init__(self, message: str, code_info: CodeInfo | None) -> None: result = [message] if code_info is not None: - result.append(" %s" % code_info.filelineno()) - result.append(" %s" % code_info.sourceline) + result.append(f" {code_info.filelineno()}") + result.append(f" {code_info.sourceline}") msg = "\n".join(result) super().__init__(msg) diff --git a/dectate/query.py b/dectate/query.py index 141969e..1b6b5fd 100644 --- a/dectate/query.py +++ b/dectate/query.py @@ -1,11 +1,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Generic, TypeVar + from .config import Composite from .error import QueryError if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Sequence + from .app import App from .config import Action, Configurable @@ -89,12 +91,10 @@ class Query(Base): def __init__(self, *action_classes: type[Action | Composite] | str) -> None: self.action_classes = action_classes - def execute( - self, configurable: Configurable - ) -> Iterator[tuple[Action, Any]]: + def execute(self, configurable: Configurable) -> Iterator[tuple[Action, Any]]: app_class = configurable.app_class assert app_class is not None - action_classes = [] + action_classes: list[type[Action | Composite]] = [] for action_class in self.action_classes: if isinstance(action_class, str): action_class = get_action_class(app_class, action_class) @@ -105,15 +105,16 @@ def execute( def expand_action_classes( action_classes: Iterable[type[Action | Composite]], ) -> set[type[Action]]: - result = set() + result: set[type[Action]] = set() for action_class in action_classes: if issubclass(action_class, Composite): query_classes = action_class.query_classes if not query_classes: - raise QueryError( - "Query of composite action %r but no " - "query_classes defined." % action_class + msg = ( + f"Query of composite action {action_class!r} but no " + "query_classes defined." ) + raise QueryError(msg) for query_class in expand_action_classes(query_classes): result.add(query_class) else: @@ -132,10 +133,8 @@ def query_action_classes( for action_class in expand_action_classes(action_classes): action_group = configurable.get_action_group(action_class) if action_group is None: - raise QueryError( - "%r is not an action of %r" - % (action_class, configurable.app_class) - ) + msg = f"{action_class!r} is not an action of {configurable.app_class!r}" + raise QueryError(msg) yield from action_group.get_actions() @@ -144,15 +143,12 @@ def get_action_class( ) -> type[Action | Composite]: directive_method = getattr(app_class, directive_name, None) if directive_method is None: - raise QueryError( - "No directive exists on %r with name: %s" - % (app_class, directive_name) - ) + msg = f"No directive exists on {app_class!r} with name: {directive_name}" + raise QueryError(msg) action_class = getattr(directive_method, "action_factory", None) if action_class is None: - raise QueryError( - f"{directive_name!r} on {app_class!r} is not a directive" - ) + msg = f"{directive_name!r} on {app_class!r} is not a directive" + raise QueryError(msg) return action_class # type: ignore[no-any-return] @@ -165,9 +161,7 @@ def __init__(self, query: Base, **kw: Any) -> None: self.query = query self.kw = kw - def execute( - self, configurable: Configurable - ) -> Iterator[tuple[Action, Any]]: + def execute(self, configurable: Configurable) -> Iterator[tuple[Action, Any]]: for action, obj in self.query.execute(configurable): for name, value in sorted(self.kw.items()): compared = action.get_value_for_filter(name) @@ -184,7 +178,7 @@ def __init__(self, query: Base, names: Sequence[str]) -> None: self.names = names def execute(self, configurable: Configurable) -> Iterator[dict[str, Any]]: - for action, obj in self.query.execute(configurable): + for action, _obj in self.query.execute(configurable): attrs = {} for name in self.names: attrs[name] = action.get_value_for_filter(name) @@ -196,5 +190,5 @@ def __init__(self, query: Base) -> None: self.query = query def execute(self, configurable: Configurable) -> Iterator[Any]: - for action, obj in self.query.execute(configurable): + for _action, obj in self.query.execute(configurable): yield obj diff --git a/dectate/sentinel.py b/dectate/sentinel.py index 5d7982e..85cb697 100644 --- a/dectate/sentinel.py +++ b/dectate/sentinel.py @@ -6,7 +6,7 @@ def __init__(self, name: str) -> None: self.name = name def __repr__(self) -> str: - return "<%s>" % self.name + return f"<{self.name}>" NOT_FOUND = Sentinel("NOT_FOUND") diff --git a/dectate/sphinxext.py b/dectate/sphinxext.py index 910365c..e4e0bec 100644 --- a/dectate/sphinxext.py +++ b/dectate/sphinxext.py @@ -5,13 +5,19 @@ obtained from the action class's ``__init__`` manually. """ +from __future__ import annotations + import inspect +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from sphinx.application import Sphinx -def setup(app): # pragma: nocoverage +def setup(app: Sphinx) -> None: # pragma: nocoverage # all inline to avoid dependency on sphinx.ext.autodoc which # would trip up scanning - from sphinx.ext.autodoc import ModuleDocumenter, MethodDocumenter + from sphinx.ext.autodoc import MethodDocumenter, ModuleDocumenter class DirectiveDocumenter(MethodDocumenter): objtype = "morepath_directive" @@ -19,16 +25,22 @@ class DirectiveDocumenter(MethodDocumenter): member_order = 49 @classmethod - def can_document_member(cls, member, membername, isattr, parent): + def can_document_member( + cls: type[MethodDocumenter], + member: Any, + membername: str, + isattr: bool, + parent: Any, + ) -> bool: return ( inspect.isroutine(member) and not isinstance(parent, ModuleDocumenter) and hasattr(member, "action_factory") ) - def import_object(self): - if not super().import_object(): - return + def import_object(self, raiseerror: bool = False) -> bool: + if not super().import_object(raiseerror): + return False object = getattr(self.object, "action_factory", None) if object is None: return False @@ -36,7 +48,9 @@ def import_object(self): self.directivetype = "classmethod" return True - def decide_to_skip(app, what, name, obj, skip, options): + def decide_to_skip( + app: Sphinx, what: str, name: str, obj: object, skip: bool, options: object + ) -> bool: if what != "class": return skip directive = getattr(obj, "action_factory", None) diff --git a/dectate/tests/fixtures/anapp.py b/dectate/tests/fixtures/anapp.py index 06b2e09..41384b1 100644 --- a/dectate/tests/fixtures/anapp.py +++ b/dectate/tests/fixtures/anapp.py @@ -2,13 +2,13 @@ class FooAction(dectate.Action): - def __init__(self, name): + def __init__(self, name) -> None: self.name = name def identifier(self): return self.name - def perform(self, obj): + def perform(self, obj) -> None: pass @@ -18,7 +18,7 @@ class AnApp(dectate.App): foo = dectate.directive(FooAction) -def other(): +def other() -> None: pass diff --git a/dectate/tests/test_directive.py b/dectate/tests/test_directive.py index 07f4d77..cbe8493 100644 --- a/dectate/tests/test_directive.py +++ b/dectate/tests/test_directive.py @@ -1,12 +1,13 @@ from __future__ import annotations -import pytest - +import logging from typing import TYPE_CHECKING, Any +import pytest + from dectate.app import App, directive -from dectate.config import commit, Action, Composite -from dectate.error import ConflictError, ConfigError +from dectate.config import Action, Composite, commit +from dectate.error import ConfigError, ConflictError if TYPE_CHECKING: from collections.abc import Callable, Generator @@ -230,9 +231,7 @@ def __init__(self, message: str) -> None: def identifier(self, foo: list[tuple[str, Any]]) -> str: return self.message - def perform( - self, obj: Callable[..., Any], foo: list[tuple[str, Any]] - ) -> None: + def perform(self, obj: Callable[..., Any], foo: list[tuple[str, Any]]) -> None: foo.append((self.message, obj)) class BarDirective(Action): @@ -244,9 +243,7 @@ def __init__(self, message: str) -> None: def identifier(self, bar: list[tuple[str, Any]]) -> str: return self.message - def perform( - self, obj: Callable[..., Any], bar: list[tuple[str, Any]] - ) -> None: + def perform(self, obj: Callable[..., Any], bar: list[tuple[str, Any]]) -> None: bar.append((self.message, obj)) class MyApp(App): @@ -277,9 +274,7 @@ def __init__(self, message: str) -> None: def identifier(self, foo: list[tuple[str, Any]]) -> str: return self.message - def perform( - self, obj: Callable[..., Any], foo: list[tuple[str, Any]] - ) -> None: + def perform(self, obj: Callable[..., Any], foo: list[tuple[str, Any]]) -> None: foo.append((self.message, obj)) class BarDirective(Action): @@ -292,9 +287,7 @@ def __init__(self, message: str) -> None: def identifier(self, foo: list[tuple[str, Any]]) -> str: return self.message - def perform( - self, obj: Callable[..., Any], foo: list[tuple[str, Any]] - ) -> None: + def perform(self, obj: Callable[..., Any], foo: list[tuple[str, Any]]) -> None: foo.append((self.message, obj)) class MyApp(App): @@ -659,10 +652,7 @@ def __init__(self, messages: list[str]) -> None: self.messages = messages def actions(self, obj: Any) -> list[tuple[SubCompositeDirective, Any]]: - return [ - (SubCompositeDirective(message), obj) - for message in self.messages - ] + return [(SubCompositeDirective(message), obj) for message in self.messages] class MyApp(App): sub = directive(SubDirective) @@ -699,9 +689,7 @@ def identifier( ) -> tuple[type[Any], str]: return (self.model, self.name) - def perform( - self, obj: Any, my: list[tuple[type[Any], str, Any]] - ) -> None: + def perform(self, obj: Any, my: list[tuple[type[Any], str, Any]]) -> None: my.append((self.model, self.name, obj)) class Dummy: @@ -748,9 +736,7 @@ def identifier( ) -> tuple[type[Any], str]: return (self.model, self.name) - def perform( - self, obj: Any, my: list[tuple[type[Any], str, Any]] - ) -> None: + def perform(self, obj: Any, my: list[tuple[type[Any], str, Any]]) -> None: my.append((self.model, self.name, obj)) class MyApp(App): @@ -790,9 +776,7 @@ def identifier( ) -> tuple[type[Any], str]: return (self.model, self.name) - def perform( - self, obj: Any, my: list[tuple[type[Any], str, Any]] - ) -> None: + def perform(self, obj: Any, my: list[tuple[type[Any], str, Any]]) -> None: my.append((self.model, self.name, obj)) class Dummy: @@ -832,9 +816,7 @@ def identifier( ) -> tuple[type[Any], str]: return (self.model, self.name) - def perform( - self, obj: Any, my: list[tuple[type[Any], str, Any]] - ) -> None: + def perform(self, obj: Any, my: list[tuple[type[Any], str, Any]]) -> None: my.append((self.model, self.name, obj)) class MyApp(App): @@ -1180,7 +1162,7 @@ def perform(self, obj: Any, my: list[tuple[str, Any]]) -> None: class MyApp(App): foo = directive(MyDirective) - for i in range(2): + for _ in range(2): @MyApp.foo("hello") def f() -> None: @@ -1191,7 +1173,7 @@ def f() -> None: def test_action_init_only_during_commit() -> None: - init_called = [] + init_called: list[str] = [] class MyDirective(Action): config = {"my": list} @@ -1378,9 +1360,7 @@ def __init__(self, message: str) -> None: def identifier(self, my: list[tuple[str, Any]], other: Other) -> str: return self.message - def perform( - self, obj: Any, my: list[tuple[str, Any]], other: Other - ) -> None: + def perform(self, obj: Any, my: list[tuple[str, Any]], other: Other) -> None: my.append((self.message, obj)) class MyApp(App): @@ -1674,9 +1654,7 @@ def __init__(self, message: str) -> None: def identifier(self, other: Other, yetanother: YetAnother) -> str: return self.message - def perform( - self, obj: Any, other: Other, yetanother: YetAnother - ) -> None: + def perform(self, obj: Any, other: Other, yetanother: YetAnother) -> None: pass class MyApp(App): @@ -1702,9 +1680,7 @@ def __init__(self, message: str) -> None: def identifier(self, my: list[tuple[str, Any]], other: Other) -> str: return self.message - def perform( - self, obj: Any, my: list[tuple[str, Any]], other: Other - ) -> None: + def perform(self, obj: Any, my: list[tuple[str, Any]], other: Other) -> None: my.append((self.message, obj)) class MyApp(App): @@ -1717,7 +1693,7 @@ class MyApp(App): # making this global to ensure the repr is the same # on Python 3.5 and earlier versions (see PEP 3155) class ReprDirective(Action): - """Doc""" + """Doc.""" config = {"my": list} @@ -1753,9 +1729,7 @@ class MyDirective(Action): def __init__(self, message: str) -> None: self.message = message - def identifier( - self, app_class: type[MyApp], my: list[tuple[str, Any]] - ) -> str: + def identifier(self, app_class: type[MyApp], my: list[tuple[str, Any]]) -> str: return self.message def perform( @@ -1972,3 +1946,256 @@ def f() -> None: commit(MyApp) assert MyApp.touched == [None] + + +def test_directive_non_class_raises_typeerror() -> None: + with pytest.raises(TypeError, match="action_factory needs to be"): + directive(lambda: None) # type: ignore[type-var] + + +def test_action_without_directive_code_info() -> None: + class MyAction(Action): + config = {} + + def __init__(self) -> None: + pass + + def identifier(self) -> str: + return "test" + + def perform(self, obj: Any) -> None: + pass + + action = MyAction() + assert action.code_info is None + + +def test_composite_without_directive_code_info() -> None: + class MyComposite(Composite): + query_classes: list[type[Action | Composite]] = [] + + def __init__(self) -> None: + pass + + def actions(self, obj: Any) -> list[tuple[Action, Any]]: + return [] + + composite = MyComposite() + assert composite.code_info is None + + +def test_action_log_when_directive_is_none() -> None: + class MyAction(Action): + config = {} + + def __init__(self) -> None: + pass + + def identifier(self) -> str: + return "test" + + def perform(self, obj: Any) -> None: + pass + + # Action created directly (not via decorator) has directive=None + action = MyAction() + # _log is a no-op when directive is None + action._log(None, None) # type: ignore[arg-type] + + +def test_commit_with_configurable_directly() -> None: + class MyAction(Action): + config = {"items": list} + + def __init__(self) -> None: + pass + + def identifier(self, items: list[str]) -> str: + return "test" + + def perform(self, obj: Any, items: list[str]) -> None: + items.append("performed") + + class MyApp(App): + foo = directive(MyAction) + + @MyApp.foo() + def f() -> None: + pass + + # commit() can accept either an App subclass or a Configurable directly + commit(MyApp.dectate) + + assert MyApp.config.items == ["performed"] + + +def test_directive_with_method_object() -> None: + class MyAction(Action): + config = {"items": list} + + def __init__(self, name: str) -> None: + self.name = name + + def identifier(self, items: list[str]) -> str: + return self.name + + def perform(self, obj: Any, items: list[str]) -> None: + items.append(obj.__name__) + + class MyApp(App): + foo = directive(MyAction) + + class MyClass: + @MyApp.foo("method1") + def method1(self) -> None: + pass + + @MyApp.foo("method2") + def method2(self) -> None: + pass + + commit(MyApp) + + assert len(MyApp.config.items) == 2 + + +def test_directive_log_with_kw_only() -> None: + class MyAction(Action): + config = {} + + def __init__(self, **kw: Any) -> None: + self.kw = kw + + def identifier(self) -> str: + return "test" + + def perform(self, obj: Any) -> None: + pass + + class MyApp(App): + foo = directive(MyAction) + + @MyApp.foo(a=1, b=2) + def f() -> None: + pass + + commit(MyApp) + + +def test_log_with_class_as_decorated_object() -> None: + # Decorating a class (not a function) exercises the repr(obj) path in Directive.log + class MyAction(Action): + config = {} + + def __init__(self, name: str) -> None: + self.name = name + + def identifier(self) -> str: + return self.name + + def perform(self, obj: Any) -> None: + pass + + class MyApp(App): + foo = directive(MyAction) + + @MyApp.foo("cls") + class MyClass: + pass + + log = logging.getLogger("dectate.directive.foo") + log.setLevel(logging.DEBUG) + try: + commit(MyApp) + finally: + log.setLevel(logging.NOTSET) + + +def test_log_with_positional_and_keyword_args() -> None: + # Using both positional and keyword args exercises the `arguments += ", "` path + class MyAction(Action): + config = {} + + def __init__(self, message: str, **extra: Any) -> None: + self.message = message + self.extra = extra + + def identifier(self) -> str: + return self.message + + def perform(self, obj: Any) -> None: + pass + + class MyApp(App): + foo = directive(MyAction) + + @MyApp.foo("hello", tag="world") + def f() -> None: + pass + + log = logging.getLogger("dectate.directive.foo") + log.setLevel(logging.DEBUG) + try: + commit(MyApp) + finally: + log.setLevel(logging.NOTSET) + + +def test_get_action_classes_from_extends_without_python_inheritance() -> None: + # Configurable.extends can be set independently of Python class inheritance. + # When the parent has action classes the child doesn't inherit via Python, + # get_action_classes() picks them up from extends._action_classes (line 110). + class FooAction(Action): + config = {} + + def __init__(self) -> None: + pass + + def identifier(self) -> str: + return "foo" + + def perform(self, obj: Any) -> None: + pass + + class ParentApp(App): + foo = directive(FooAction) + + class ChildApp(App): # Does NOT Python-inherit from ParentApp + pass + + ChildApp.dectate.extends = [ParentApp.dectate] + + commit(ParentApp) # Populates ParentApp.dectate._action_classes + commit(ChildApp) # FooAction comes from extends loop, not dir(ChildApp) + + assert FooAction in ChildApp.dectate.get_action_classes() + + +def test_factory_argument_with_null_dependency() -> None: + # A factory whose dependency returns None triggers the ConfigError at line 1070 + def null_factory() -> None: + return None + + class DependentFactory: + factory_arguments = {"null": null_factory} + + def __init__(self, null: Any) -> None: + self.null = null + + class MyAction(Action): + config = {"items": DependentFactory} + + def __init__(self) -> None: + pass + + def identifier(self) -> str: + return "test" + + def perform(self, obj: Any, items: Any) -> None: + pass + + class MyApp(App): + my = directive(MyAction) + + with pytest.raises(ConfigError): + commit(MyApp) diff --git a/dectate/tests/test_error.py b/dectate/tests/test_error.py index 834a713..53cdf3d 100644 --- a/dectate/tests/test_error.py +++ b/dectate/tests/test_error.py @@ -1,14 +1,14 @@ from __future__ import annotations -import pytest - from typing import Any, NoReturn +import pytest + from dectate.app import App, directive -from dectate.config import commit, Action, Composite +from dectate.config import Action, Composite, commit from dectate.error import ( - ConflictError, ConfigError, + ConflictError, DirectiveError, DirectiveReportError, ) @@ -23,7 +23,8 @@ def identifier(self) -> str: return self.name def perform(self, obj: Any) -> NoReturn: - raise DirectiveError("A real problem") + msg = "A real problem" + raise DirectiveError(msg) class MyApp(App): foo = directive(FooDirective) @@ -47,7 +48,8 @@ def __init__(self, name: str) -> None: self.name = name def actions(self, obj: Any) -> NoReturn: - raise DirectiveError("Something went wrong") + msg = "Something went wrong" + raise DirectiveError(msg) class MyApp(App): foo = directive(FooDirective) @@ -74,7 +76,8 @@ def identifier(self) -> str: return self.name def perform(self, obj: Any) -> NoReturn: - raise DirectiveError("A real problem") + msg = "A real problem" + raise DirectiveError(msg) class MyApp(App): foo = directive(FooDirective) @@ -107,7 +110,8 @@ def identifier(self) -> tuple[type[Any], str]: return (self.model, self.name) def perform(self, obj: Any) -> NoReturn: - raise DirectiveError("A real problem") + msg = "A real problem" + raise DirectiveError(msg) class MyApp(App): foo = directive(FooDirective) @@ -131,7 +135,7 @@ def g() -> None: value = str(e.value) assert value.startswith("A real problem") - assert value.endswith(' @foo(name="a")') + assert ' @foo(name="a")' in value assert "/test_error.py" in value @@ -454,3 +458,25 @@ def f() -> None: pass commit(MyApp) + + +def test_conflict_error_with_none_code_info() -> None: + # ConflictError must handle actions whose code_info is None + # (actions created manually, not via a decorator) + class MyAction(Action): + config = {} + + def __init__(self) -> None: + pass + + def identifier(self) -> str: + return "test" + + def perform(self, obj: Any) -> None: + pass + + action1 = MyAction() + action2 = MyAction() + + error = ConflictError([action1, action2]) + assert "Conflict between:" in str(error) diff --git a/dectate/tests/test_helpers.py b/dectate/tests/test_helpers.py index 1b5fe44..d70eaf0 100644 --- a/dectate/tests/test_helpers.py +++ b/dectate/tests/test_helpers.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys + from ..config import CodeInfo, create_code_info @@ -11,7 +12,7 @@ def current_code_info() -> CodeInfo: def test_create_code_info() -> None: x = current_code_info() assert x.path == __file__ - assert x.lineno == 12 + assert x.lineno == 13 assert x.sourceline == "x = current_code_info()" x = eval("current_code_info()") diff --git a/dectate/tests/test_logging.py b/dectate/tests/test_logging.py index 0511be4..37b66c3 100644 --- a/dectate/tests/test_logging.py +++ b/dectate/tests/test_logging.py @@ -2,12 +2,13 @@ import logging from typing import Any + from dectate.app import App, directive from dectate.config import Action, commit class Handler(logging.Handler): - def __init__(self, level: int | str = logging.NOTSET): + def __init__(self, level: int | str = logging.NOTSET) -> None: super().__init__(level) self.records: list[logging.LogRecord] = [] @@ -62,8 +63,7 @@ def f() -> None: messages = [r.getMessage() for r in test_handler.records] assert len(messages) == 1 expected = ( - "@dectate.tests.test_logging.MyApp.foo('hello') " - "on dectate.tests.test_logging.f" + "@dectate.tests.test_logging.MyApp.foo('hello') on dectate.tests.test_logging.f" ) assert messages[0] == expected @@ -104,8 +104,7 @@ def f() -> None: messages = [r.getMessage() for r in test_handler.records] assert len(messages) == 2 expected = ( - "@dectate.tests.test_logging.MyApp.foo('hello') " - "on dectate.tests.test_logging.f" + "@dectate.tests.test_logging.MyApp.foo('hello') on dectate.tests.test_logging.f" ) assert messages[0] == expected @@ -153,8 +152,7 @@ def f() -> None: messages = [r.getMessage() for r in test_handler.records] assert len(messages) == 1 expected = ( - "@dectate.tests.test_logging.MyApp.foo('hello') " - "on dectate.tests.test_logging.f" + "@dectate.tests.test_logging.MyApp.foo('hello') on dectate.tests.test_logging.f" ) assert messages[0] == expected diff --git a/dectate/tests/test_query.py b/dectate/tests/test_query.py index 46074bc..e2c72b4 100644 --- a/dectate/tests/test_query.py +++ b/dectate/tests/test_query.py @@ -1,22 +1,23 @@ from __future__ import annotations -import pytest - from typing import TYPE_CHECKING, Any +import pytest + from dectate import ( - Query, - App, + NOT_FOUND, Action, + App, Composite, - directive, - commit, + Query, QueryError, - NOT_FOUND, + commit, + directive, ) if TYPE_CHECKING: from collections.abc import Generator + from dectate import Sentinel @@ -123,7 +124,7 @@ def g() -> None: q = Query(FooAction, BarAction).attrs("name") - assert sorted(list(q(MyApp)), key=lambda d: d["name"]) == [ + assert sorted(q(MyApp), key=lambda d: d["name"]) == [ {"name": "a"}, {"name": "b"}, ] @@ -177,9 +178,7 @@ def identifier( ) -> tuple[type[Any], str]: return (self.model, self.name) - def perform( - self, obj: Any, registry: list[tuple[type[Any], str, Any]] - ) -> None: + def perform(self, obj: Any, registry: list[tuple[type[Any], str, Any]]) -> None: registry.append((self.model, self.name, obj)) class MyApp(App): @@ -394,14 +393,10 @@ class ViewAction(Action): def __init__(self, model: type[Any]) -> None: self.model = model - def identifier( - self, registry: list[tuple[type[Any], Any]] - ) -> type[Any]: + def identifier(self, registry: list[tuple[type[Any], Any]]) -> type[Any]: return self.model - def perform( - self, obj: Any, registry: list[tuple[type[Any], Any]] - ) -> None: + def perform(self, obj: Any, registry: list[tuple[type[Any], Any]]) -> None: registry.append((self.model, obj)) class MyApp(App): @@ -548,7 +543,7 @@ def g() -> None: q = Query(FooAction, BarAction).attrs("name") - assert sorted(list(q(MyApp)), key=lambda d: d["name"]) == [ + assert sorted(q(MyApp), key=lambda d: d["name"]) == [ {"name": "a"}, {"name": "b"}, ] @@ -691,7 +686,7 @@ def __init__(self, amount: int) -> None: def actions(self, obj: Any) -> Generator[tuple[SubAction, Any]]: for i in range(self.amount): - yield SubAction(["a%s" % i, "b%s" % i]), obj + yield SubAction([f"a{i}", f"b{i}"]), obj class MyApp(App): _subsub = directive(SubSubAction) @@ -706,7 +701,7 @@ def f() -> None: q = Query(CompositeAction).attrs("name") - assert sorted(list(q(MyApp)), key=lambda d: d["name"]) == [ + assert sorted(q(MyApp), key=lambda d: d["name"]) == [ {"name": "a0"}, {"name": "a1"}, {"name": "b0"}, @@ -759,3 +754,76 @@ def g() -> None: with pytest.raises(QueryError): list(q(MyApp)) + + +def test_filter_by_nonexistent_attribute() -> None: + # Filtering by an attribute the action doesn't have, with no filter_get_value, + # exercises the NOT_FOUND early-return branch in get_value_for_filter (line 513). + class FooAction(Action): + config = {"registry": list} + + def __init__(self, name: str) -> None: + self.name = name + + def identifier(self, registry: list[Any]) -> str: + return self.name + + def perform(self, obj: Any, registry: list[Any]) -> None: + registry.append(obj) + + class MyApp(App): + foo = directive(FooAction) + + @MyApp.foo("a") + def f() -> None: + pass + + commit(MyApp) + + results = list(Query(FooAction).filter(nonexistent_attr="x")(MyApp)) + assert results == [] + + +def test_filter_with_callable_fallback() -> None: + class CustomAction(Action): + config = {} + filter_name = {"short": "long_name"} + + def filter_get_value(self, name: str) -> Any: + if name == "long_name": + return "custom_value" + return NOT_FOUND + + def __init__(self, msg: str) -> None: + self.msg = msg + self.long_name = "custom_value" + + def identifier(self) -> str: + return self.msg + + def perform(self, obj: Any) -> None: + pass + + class MyApp(App): + custom = directive(CustomAction) + + @MyApp.custom("test") + def f() -> None: + pass + + commit(MyApp) + + results = list(Query(CustomAction).filter(short="custom_value")(MyApp)) + assert len(results) == 1 + + +def test_callable_execute_abstract() -> None: + # Callable.execute raises NotImplementedError to signal subclasses must override it. + from dectate.query import Callable as QueryCallable + + class ConcreteCallable(QueryCallable[Any]): + pass + + c = ConcreteCallable() + with pytest.raises(NotImplementedError): + c.execute(None) # type: ignore[arg-type] diff --git a/dectate/tests/test_tool.py b/dectate/tests/test_tool.py index 84129fd..1464605 100644 --- a/dectate/tests/test_tool.py +++ b/dectate/tests/test_tool.py @@ -1,21 +1,26 @@ from __future__ import annotations -import pytest +import sys from argparse import ArgumentTypeError from typing import Any +from unittest.mock import patch + +import pytest -from dectate.config import Action, commit from dectate.app import App, directive +from dectate.config import Action, commit from dectate.tool import ( + ToolError, + convert_bool, + convert_dotted_name, + convert_filters, parse_app_class, parse_directive, parse_filters, - convert_filters, - convert_dotted_name, - convert_bool, - query_tool_output, query_app, - ToolError, + query_tool, + query_tool_output, + resolve_dotted_name, ) @@ -101,9 +106,7 @@ class MyAction(Action): filter_convert = {"model": convert_dotted_name} with pytest.raises(ToolError): - convert_filters( - MyAction, {"model": "dectate.tests.fixtures.anapp.DoesntExist"} - ) + convert_filters(MyAction, {"model": "dectate.tests.fixtures.anapp.DoesntExist"}) def test_convert_filters_value_error() -> None: @@ -298,3 +301,74 @@ def g() -> None: li = list(query_app(SubApp, "foo")) assert len(li) == 2 + + +def test_resolve_dotted_name_relative_without_module() -> None: + with pytest.raises(ValueError, match="relative name without base module"): + resolve_dotted_name(".foo") + + +def test_resolve_dotted_name_relative_with_module() -> None: + # Single leading dot: resolve relative to the given module + import dectate.tests as dt + + result = resolve_dotted_name(".tests", "dectate") + assert result is dt + + +def test_resolve_dotted_name_relative_multilevel() -> None: + # Double leading dot: go up one package level before resolving + import dectate.tests as dt + + result = resolve_dotted_name("..tests", "dectate.something") + assert result is dt + + +def test_resolve_dotted_name_submodule_not_auto_imported() -> None: + # sphinxext is not imported by dectate.__init__, so getattr(dectate, "sphinxext") + # fails and __import__("dectate.sphinxext") is called (line 222 in tool.py). + # That import also covers the only executable line in sphinxext.py. + import dectate + + result = resolve_dotted_name("dectate.sphinxext") + assert result is dectate.sphinxext # type: ignore[attr-defined] + + +def test_query_tool_main(capsys: pytest.CaptureFixture[str]) -> None: + class FooAction(Action): + def __init__(self, name: str) -> None: + self.name = name + + def identifier(self) -> str: + return self.name + + def perform(self, obj: Any) -> None: + pass + + class MyApp(App): + foo = directive(FooAction) + + @MyApp.foo("a") + def f() -> None: + pass + + commit(MyApp) + + with patch.object(sys, "argv", ["decq", "foo"]): + query_tool([MyApp]) + + assert capsys.readouterr().out + + +def test_query_tool_with_app_arg() -> None: + # Passing --app exercises the `app_classes = args.app` branch. + # AnApp is not committed so ToolError is raised -> parser.error -> SystemExit. + with ( + patch.object( + sys, + "argv", + ["decq", "--app", "dectate.tests.fixtures.anapp.AnApp", "foo"], + ), + pytest.raises(SystemExit), + ): + query_tool([]) diff --git a/dectate/tests/test_toposort.py b/dectate/tests/test_toposort.py index 0f20f2a..3fdd735 100644 --- a/dectate/tests/test_toposort.py +++ b/dectate/tests/test_toposort.py @@ -1,7 +1,7 @@ -from dectate import topological_sort, TopologicalSortError - import pytest +from dectate import TopologicalSortError, topological_sort + def test_topological_sort_on_dcg() -> None: adjacency = { diff --git a/dectate/tool.py b/dectate/tool.py index 8c73838..25b3375 100644 --- a/dectate/tool.py +++ b/dectate/tool.py @@ -3,12 +3,14 @@ import argparse import inspect from typing import TYPE_CHECKING, Any -from .query import Query, get_action_class -from .error import QueryError + from .app import App +from .error import QueryError +from .query import Query, get_action_class if TYPE_CHECKING: from collections.abc import Iterable, Iterator + from .config import Action, Composite from .query import Filter @@ -68,20 +70,21 @@ def query_tool_output( ) -> Iterator[str]: for app_class in app_classes: if not app_class.is_committed(): - raise ToolError("App %r was not committed." % app_class) + msg = f"App {app_class!r} was not committed." + raise ToolError(msg) actions = list(query_app(app_class, directive, **filters)) if not actions: continue - yield "App: %r" % app_class + yield f"App: {app_class!r}" - for action, obj in actions: + for action, _obj in actions: if action.directive is None: - continue # XXX handle this case - yield " %s" % action.directive.code_info.filelineno() - yield " %s" % action.directive.code_info.sourceline + continue # pragma: no cover # XXX handle this case + yield f" {action.directive.code_info.filelineno()}" + yield f" {action.directive.code_info.sourceline}" yield "" @@ -120,13 +123,14 @@ def parse_app_class(s: str) -> type[App]: try: app_class = resolve_dotted_name(s) except ImportError: - raise argparse.ArgumentTypeError("Cannot resolve dotted name: %r" % s) + msg = f"Cannot resolve dotted name: {s!r}" + raise argparse.ArgumentTypeError(msg) if not inspect.isclass(app_class): - raise argparse.ArgumentTypeError("%r is not a class" % s) + msg = f"{s!r} is not a class" + raise argparse.ArgumentTypeError(msg) if not issubclass(app_class, App): - raise argparse.ArgumentTypeError( - "%r is not a subclass of dectate.App" % s - ) + msg = f"{s!r} is not a subclass of dectate.App" + raise argparse.ArgumentTypeError(msg) return app_class @@ -150,7 +154,8 @@ def convert_dotted_name(s: str) -> Any: try: return resolve_dotted_name(s) except ImportError: - raise ToolError("Cannot resolve dotted name: %s" % s) + msg = f"Cannot resolve dotted name: {s}" + raise ToolError(msg) def convert_bool(s: str) -> bool: @@ -160,19 +165,20 @@ def convert_bool(s: str) -> bool: """ if s == "True": return True - elif s == "False": + if s == "False": return False - else: - raise ValueError("Cannot convert bool: %r" % s) + msg = f"Cannot convert bool: {s!r}" + raise ValueError(msg) def parse_filters(entries: Iterable[str]) -> dict[str, str]: - result = {} + result: dict[str, str] = {} for entry in entries: try: name, value = entry.split("=") except ValueError: - raise ToolError("Cannot parse query filter, no =.") + msg = "Cannot parse query filter, no =." + raise ToolError(msg) name = name.strip() result[name] = value.strip() return result @@ -183,7 +189,7 @@ def convert_filters( ) -> dict[str, Any]: filter_convert = action_class.filter_convert - result = {} + result: dict[str, Any] = {} for key, value in filters.items(): parse = filter_convert.get(key, convert_default) @@ -196,11 +202,12 @@ def convert_filters( def resolve_dotted_name(name: str, module: str | None = None) -> Any: - """Adapted from zope.dottedname""" + """Adapted from zope.dottedname.""" name_parts = name.split(".") if not name_parts[0]: if module is None: - raise ValueError("relative name without base module") + msg = "relative name without base module" + raise ValueError(msg) module_parts = module.split(".") name_parts.pop(0) if TYPE_CHECKING: diff --git a/dectate/toposort.py b/dectate/toposort.py index f4b9fcb..34636cf 100644 --- a/dectate/toposort.py +++ b/dectate/toposort.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, TypeVar + from .error import TopologicalSortError if TYPE_CHECKING: @@ -10,9 +11,10 @@ def topological_sort( - l: Iterable[_T], get_depends: Callable[[_T], Iterable[_T]] # noqa: E741 + l: Iterable[_T], + get_depends: Callable[[_T], Iterable[_T]], ) -> list[_T]: - """`Topological sort`_ + """`Topological sort`_. .. _`Topological sort`: https://en.wikipedia.org/wiki/Topological_sorting @@ -29,15 +31,16 @@ def topological_sort( :return: a list of the given items sorted topologically. """ - result = [] - marked = set() - temporary_marked = set() + result: list[_T] = [] + marked: set[_T] = set() + temporary_marked: set[_T] = set() def visit(n: _T) -> None: if n in marked: return if n in temporary_marked: - raise TopologicalSortError("Not a DAG") + msg = "Not a DAG" + raise TopologicalSortError(msg) temporary_marked.add(n) for m in get_depends(n): visit(m) diff --git a/develop_requirements.txt b/develop_requirements.txt index 780e977..f08b794 100644 --- a/develop_requirements.txt +++ b/develop_requirements.txt @@ -1,8 +1,7 @@ # development --e '.[test,coverage,lint,docs]' +-e '.[test,coverage,lint,docs,mypy,pyright]' pre-commit tox >= 4 -radon # releaser zest.releaser[recommended] diff --git a/doc/conf.py b/doc/conf.py index a6ebd8d..d28d024 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -10,7 +10,7 @@ # serve to show the default. import os -import pkg_resources +from importlib import metadata # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -35,9 +35,10 @@ autodoc_member_order = "groupwise" intersphinx_mapping = { - "reg": ("http://reg.readthedocs.io/en/latest", None), - "webob": ("http://docs.webob.org/en/latest", None), - "bowerstatic": ("http://bowerstatic.readthedocs.io/en/latest", None), + "reg": ("https://reg.readthedocs.io/en/latest", None), + "webob": ("https://docs.pylonsproject.org/projects/webob/en/latest", None), + "bowerstatic": ("https://bowerstatic.readthedocs.io/en/latest", None), + "python": ("https://docs.python.org/3/", None), } # Add any paths that contain templates here, relative to this directory. @@ -64,17 +65,31 @@ # built documents. # # The short X.Y version. -version = pkg_resources.get_distribution("dectate").version +try: + version = metadata.version("dectate") +except metadata.PackageNotFoundError: + # Fallback for ReadTheDocs and other environments where the package isn't installed + # Try to get version from pyproject.toml + import re + + try: + pyproject_path = os.path.join(os.path.dirname(__file__), "..", "pyproject.toml") + with open(pyproject_path) as f: + content = f.read() + # Simple regex to extract version + version_match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) + version = version_match.group(1) if version_match else "0.0.0" + except (FileNotFoundError, Exception): + version = "0.0.0" # The full version, including alpha/beta/rc tags. release = version - # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -147,7 +162,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = [] +html_static_path: list[str] = [] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -214,7 +229,7 @@ # -- Options for LaTeX output --------------------------------------------- -latex_elements = { +latex_elements: dict[str, str] = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). @@ -296,7 +311,3 @@ # texinfo_show_urls = 'footnote' doctest_path = [os.path.abspath("..")] - - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"https://docs.python.org/3/": None} diff --git a/doc/developing.rst b/doc/developing.rst index a2ed1c7..8a0ec69 100644 --- a/doc/developing.rst +++ b/doc/developing.rst @@ -21,15 +21,11 @@ Make sure you have virtualenv_ installed. Create a new virtualenv for Python 3 inside the dectate directory:: - $ virtualenv -p python3 env/py3 + $ python -m venv --upgrade-deps .venv Activate the virtualenv:: - $ source env/py3/bin/activate - -Make sure you have recent setuptools and pip installed:: - - $ pip install -U setuptools pip + $ source .venv/bin/activate Install the various dependencies and development tools from develop_requirements.txt:: @@ -42,9 +38,8 @@ For upgrading the requirements just run the command again. The following commands work only if you have the virtualenv activated. -.. _github: https://help.github.com/articles/generating-an-ssh-key - -.. _virtualenv: https://pypi.python.org/pypi/virtualenv +.. _github: https://docs.github.com/en/authentication/connecting-to-github-with-ssh +.. _virtualenv: https://pypi.org/project/virtualenv Install pre-commit hook for Black integration --------------------------------------------- @@ -54,7 +49,7 @@ install the `pre-commit hook`_ for Black integration before committing:: $ pre-commit install -.. _`pre-commit hook`: https://black.readthedocs.io/en/stable/version_control_integration.html +.. _`pre-commit hook`: https://black.readthedocs.io/en/stable/integrations/source_version_control.html Running the tests ----------------- @@ -71,14 +66,14 @@ You can then point your web browser to the ``htmlcov/index.html`` file in the project directory and click on modules to see detailed coverage information. -.. _`py.test`: http://pytest.org/latest/ +.. _`py.test`: https://pytest.org/latest/ Black ----- To format the code with the `Black Code Formatter`_ run in the root directory:: - $ black morepath + $ black dectate Black has also integration_ for the most popular editors. @@ -105,9 +100,9 @@ Or from the Dectate project directory:: Building the HTML documentation ------------------------------- -To build the HTML documentation (output in ``doc/build/html``), run:: +To build the HTML documentation (output in ``doc/_build/html``), run:: - $ sphinx-build doc doc/build/html + $ sphinx-build doc doc/_build/html Or alternatively if you have ``Make`` installed:: @@ -132,11 +127,11 @@ To also show cyclomatic complexity, use this command:: $ flake8 --max-complexity=10 dectate -.. _flake8: https://pypi.python.org/pypi/flake8 +.. _flake8: https://pypi.org/project/flake8 -.. _pyflakes: https://pypi.python.org/pypi/pyflakes +.. _pyflakes: https://pypi.org/project/pyflakes -.. _pep8: http://www.python.org/dev/peps/pep-0008/ +.. _pep8: https://peps.python.org/pep-0008 .. _`cyclomatic complexity`: https://en.wikipedia.org/wiki/Cyclomatic_complexity @@ -169,4 +164,4 @@ You can also specify a test environment to run e.g.:: $ tox -e lint $ tox -e docs -.. _pyenv: https://github.com/yyuu/pyenv +.. _pyenv: https://github.com/pyenv/pyenv diff --git a/doc/history.rst b/doc/history.rst index 0f5d7de..bd1c63a 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -8,7 +8,7 @@ In the beginning (around 2001) there was `zope.configuration`_, part of the Zope 3 project. It features declarative XML configuration with conflict detection and overrides to assemble pieces of Python code. -.. _`zope.configuration`: https://pypi.python.org/pypi/zope.configuration +.. _`zope.configuration`: https://pypi.org/project/zope.configuration In 2006, I helped create the Grok project. This did away with the XML based configuration and instead used Python code. This in turn then @@ -17,13 +17,13 @@ instead used specially annotated Python classes, which were recursively scanned from modules. Grok's configuration system was spun off as the Martian_ library. -.. _Martian: https://pypi.python.org/pypi/martian +.. _Martian: https://pypi.org/project/martian Chris McDonough was then inspired by Martian to create Venusian_, a deferred decorator execution system. It is like Martian in that it imports Python modules recursively in order to find configuration. -.. _Venusian: https://pypi.python.org/pypi/venusian +.. _Venusian: https://pypi.org/project/venusian I created the Morepath_ web framework, which uses decorators for configuration throughout and used Venusian. Morepath grew a @@ -32,7 +32,7 @@ classes, and uses class inheritance to power configuration reuse and overrides. This configuration subsystem started to get a bit messy as requirements grew. -.. _Morepath: http://morepath.readthedocs.io +.. _Morepath: https://morepath.readthedocs.io So in 2016 I extracted the configuration system from Morepath into its own library, Dectate. This allowed me to extensively refactor the code diff --git a/doc/usage.rst b/doc/usage.rst index 2deca4d..9dfe289 100644 --- a/doc/usage.rst +++ b/doc/usage.rst @@ -1,6 +1,8 @@ Using Dectate ============= +.. py:currentmodule:: dectate + Introduction ------------ @@ -51,7 +53,7 @@ particular order. Dectate supports such advanced use cases. It was extracted from the Morepath_ web framework. -.. _Morepath: http://morepath.readthedocs.io +.. _Morepath: https://morepath.readthedocs.io Features -------- @@ -105,7 +107,7 @@ Actions In Dectate, the simple `plugins` example above looks like this: -.. testcode:: +.. code-block:: python import dectate @@ -132,14 +134,14 @@ Configuration in Dectate is associated with special *classes* which derive from :class:`dectate.App`. We also associate the action with it as a directive: -.. testcode:: +.. code-block:: python class PluginApp(dectate.App): plugin = dectate.directive(PluginAction) Let's use it now: -.. testcode:: +.. code-block:: python @PluginApp.plugin('a') def f(): @@ -154,14 +156,14 @@ argument is ``'a'``. We've registered ``g`` under ``'b'``. We can now commit the configuration for ``PluginApp``: -.. testcode:: +.. code-block:: python dectate.commit(PluginApp) Once the commit has successfully completed, we can take a look at the configuration: -.. doctest:: +.. code-block:: pycon >>> sorted(PluginApp.config.plugins.items()) [('a', ), ('b', )] @@ -180,19 +182,21 @@ Reuse You can reuse configuration by simply subclassing ``PluginApp``: -.. testcode:: +.. code-block:: python class SubApp(PluginApp): pass We commit both classes: -.. testcode:: +.. code-block:: python dectate.commit(PluginApp, SubApp) ``SubClass`` now contains all the configuration declared for ``PluginApp``: +.. code-block:: pycon + >>> sorted(SubApp.config.plugins.items()) [('a', ), ('b', )] @@ -204,7 +208,7 @@ Conflicts Consider this example: -.. testcode:: +.. code-block:: python class ConflictingApp(PluginApp): pass @@ -221,7 +225,7 @@ Which function should be registered for ``foo``, ``f`` or ``g``? We should refuse to guess and instead raise an error that the configuration is in conflict. This is exactly what Dectate does: -.. doctest:: +.. code-block:: pycon >>> dectate.commit(ConflictingApp) Traceback (most recent call last): @@ -251,7 +255,7 @@ Extension When you subclass configuration, you can also *extend* ``SubApp`` with additional configuration actions: -.. testcode:: +.. code-block:: python @SubApp.plugin('c') def h(): @@ -261,14 +265,14 @@ additional configuration actions: ``SubApp`` now has the additional plugin ``c``: -.. doctest:: +.. code-block:: pycon >>> sorted(SubApp.config.plugins.items()) [('a', ), ('b', ), ('c', )] But ``PluginApp`` is unaffected: -.. doctest:: +.. code-block:: pycon >>> sorted(PluginApp.config.plugins.items()) [('a', ), ('b', )] @@ -279,7 +283,7 @@ Overrides What if you wanted to override a piece of configuration? You can do this in ``SubApp`` by simply reusing the same ``name``: -.. testcode:: +.. code-block:: python @SubApp.plugin('a') def x(): @@ -292,13 +296,15 @@ register the function ``x`` instead of ``f``. If we had done this for ``MyApp`` this would have been a conflict, but doing so in a subclass lets you override configuration instead: -.. doctest:: +.. code-block:: pycon >>> sorted(SubApp.config.plugins.items()) [('a', ), ('b', ), ('c', )] But ``PluginApp`` still uses ``f``: +.. code-block:: pycon + >>> sorted(PluginApp.config.plugins.items()) [('a', ), ('b', )] @@ -313,7 +319,7 @@ each other. We first set up a new base class with a directive, independently from everything before: -.. testcode:: +.. code-block:: python class PluginAction2(dectate.Action): config = { @@ -334,7 +340,7 @@ from everything before: We don't set up any configuration for ``BaseApp``; it's intended to be part of our framework. Now we create two subclasses: -.. testcode:: +.. code-block:: python class OneApp(BaseApp): pass @@ -347,7 +353,7 @@ each other; the only thing they share is a common ``BaseApp``. We register a plugin for ``OneApp``: -.. testcode:: +.. code-block:: python @OneApp.plugin('a') def f(): @@ -355,11 +361,11 @@ We register a plugin for ``OneApp``: This won't affect ``TwoApp`` in any way: -.. testcode:: +.. code-block:: python dectate.commit(OneApp, TwoApp) -.. doctest:: +.. code-block:: pycon >>> sorted(OneApp.config.plugins.items()) [('a', )] @@ -445,7 +451,7 @@ attribute. First we set up a ``FooAction`` that registers into a ``foos`` dict: -.. testcode:: +.. code-block:: python class FooAction(dectate.Action): config = { @@ -463,7 +469,7 @@ dict: Now we create a ``BarAction`` directive that depends on ``FooAction`` and uses information in the ``foos`` dict: -.. testcode:: +.. code-block:: python class BarAction(dectate.Action): depends = [FooAction] @@ -485,7 +491,7 @@ and uses information in the ``foos`` dict: In order to use them we need to hook up the actions as directives onto an app class: -.. testcode:: +.. code-block:: python class DependsApp(dectate.App): foo = dectate.directive(FooAction) @@ -496,7 +502,7 @@ Using ``depends`` we have ensured that ``BarAction`` actions are performed after ``FooAction`` action, no matter what order we use them: -.. testcode:: +.. code-block:: python @DependsApp.bar('a') def f(): @@ -515,7 +521,7 @@ them: We expect ``in_foo`` to be ``True`` for ``a`` but to be ``False`` for ``b``: -.. doctest:: +.. code-block:: pycon >>> DependsApp.config.bars [('a', , True), ('b', , False)] @@ -539,7 +545,7 @@ in ``config`` of that earlier action. First we create a ``FooAction`` that sets up a ``foos`` config item as before: -.. testcode:: +.. code-block:: python class FooAction(dectate.Action): config = { @@ -557,7 +563,7 @@ before: Now we create a ``Bar`` class that also depends on the ``foos`` dict by listing it in ``factory_arguments``: -.. testcode:: +.. code-block:: python class Bar: factory_arguments = { @@ -575,7 +581,7 @@ listing it in ``factory_arguments``: We create a ``BarAction`` that depends on the ``FooAction`` (so that ``foos`` is created first) and that uses the ``Bar`` factory: -.. testcode:: +.. code-block:: python class BarAction(dectate.Action): depends = [FooAction] @@ -596,7 +602,7 @@ We create a ``BarAction`` that depends on the ``FooAction`` (so that And we set them up as directives: -.. testcode:: +.. code-block:: python class ConfigDependsApp(dectate.App): foo = dectate.directive(FooAction) @@ -604,7 +610,7 @@ And we set them up as directives: When we use our directives: -.. testcode:: +.. code-block:: python @ConfigDependsApp.bar('a') def f(): @@ -622,7 +628,7 @@ When we use our directives: we get the same result as before: -.. doctest:: +.. code-block:: pycon >>> ConfigDependsApp.config.bar.l [('a', , True), ('b', , False)] @@ -636,7 +642,7 @@ another way. You can get the app class passed in as an argument to :meth:`dectate.Action.perform`, :meth:`dectate.Action.identifier`, and so on by setting the special ``app_class_arg`` class attribute: -.. testcode:: +.. code-block:: python class PluginAction(dectate.Action): config = { @@ -659,7 +665,7 @@ so on by setting the special ``app_class_arg`` class attribute: When we now perform this directive: -.. testcode:: +.. code-block:: python @MyApp.plugin_with_app_class('a') def f(): @@ -669,7 +675,7 @@ When we now perform this directive: We can see the app class was indeed affected: -.. doctest:: +.. code-block:: pycon >>> MyApp.touched True @@ -685,7 +691,7 @@ of a certain type are performed, or just afterwards. You can do this using ``before`` (:meth:`dectate.Action.before`) and ``after`` (:meth:`dectate.Action.after`) static methods on the Action class: -.. testcode:: +.. code-block:: python class FooAction(dectate.Action): config = { @@ -722,7 +728,7 @@ using ``before`` (:meth:`dectate.Action.before`) and ``after`` This executes ``before`` just before ``a`` and ``b`` are configured, and then executes ``after``: -.. doctest:: +.. code-block:: pycon >>> dectate.commit(BeforeAfterApp) before: [] @@ -737,7 +743,7 @@ affect each other. You can do this with the ``group_class`` (:attr:`dectate.Action.group_class`) class attribute. Grouped classes share their ``config`` and their ``before`` and ``after`` methods. -.. testcode:: +.. code-block:: python class FooAction(dectate.Action): config = { @@ -754,7 +760,7 @@ share their ``config`` and their ``before`` and ``after`` methods. We now create a ``BarAction`` that groups with ``FooAction``: -.. testcode:: +.. code-block:: python class BarAction(dectate.Action): group_class = FooAction @@ -775,7 +781,7 @@ We now create a ``BarAction`` that groups with ``FooAction``: It reuses the ``config`` from ``FooAction``. This means that ``foo`` and ``bar`` can be in conflict: -.. testcode:: +.. code-block:: python @GroupApp.foo('a') def f(): @@ -785,7 +791,7 @@ and ``bar`` can be in conflict: def g(): pass -.. doctest:: +.. code-block:: pycon >>> dectate.commit(GroupApp) Traceback (most recent call last): @@ -803,7 +809,7 @@ In some cases an action should conflict with *multiple* other actions all at once. You can take care of this with the ``discriminators`` (:meth:`dectate.Action.discriminators`) method on your action: -.. testcode:: +.. code-block:: python class FooAction(dectate.Action): config = { @@ -829,7 +835,7 @@ all at once. You can take care of this with the ``discriminators`` An action now conflicts with an action of the same name *and* with any action that is in the ``extra`` list: -.. testcode:: +.. code-block:: python # example @DiscriminatorsApp.foo('a', ['b', 'c']) @@ -842,7 +848,7 @@ any action that is in the ``extra`` list: And then: -.. doctest:: +.. code-block:: pycon >>> dectate.commit(DiscriminatorsApp) Traceback (most recent call last): @@ -862,7 +868,7 @@ can subclass :class:`dectate.Composite`. First we define a normal ``SubAction`` to use in the composite action later: -.. testcode:: +.. code-block:: python class SubAction(dectate.Action): config = { @@ -882,7 +888,7 @@ Now we can define a special :class:`dectate.Composite` subclass that uses ``SubAction`` in an ``actions`` (:meth:`dectate.Composite.actions`) method: -.. testcode:: +.. code-block:: python class CompositeAction(dectate.Composite): def __init__(self, names): @@ -901,7 +907,7 @@ subclass, as Dectate does need to know it exists. We can now use it: -.. testcode:: +.. code-block:: python @CompositeApp.composite(['a', 'b', 'c']) def f(): @@ -911,7 +917,7 @@ We can now use it: And ``SubAction`` is performed three times as a result: -.. doctest:: +.. code-block:: pycon >>> CompositeApp.config.my [('a', ), ('b', ), ('c', )] @@ -922,7 +928,7 @@ And ``SubAction`` is performed three times as a result: Sometimes you want to issue a lot of similar actions at once. You can use the ``with`` statement to do so with less repetition: -.. testcode:: +.. code-block:: python class FooAction(dectate.Action): config = { @@ -945,7 +951,7 @@ use the ``with`` statement to do so with less repetition: Instead of this: -.. testcode:: +.. code-block:: python class VerboseWithApp(WithApp): pass @@ -964,7 +970,7 @@ Instead of this: You can instead write: -.. testcode:: +.. code-block:: python class SuccinctWithApp(WithApp): pass @@ -984,7 +990,7 @@ You can instead write: And this has the same configuration effect: -.. doctest:: +.. code-block:: pycon >>> dectate.commit(VerboseWithApp, SuccinctWithApp) >>> VerboseWithApp.config.my @@ -1035,14 +1041,13 @@ using :class:`dectate.Query`. Here is an example of a query for all the plugin actions on ``PluginApp``: -.. testcode:: +.. code-block:: python q = dectate.Query('plugin') We can now run the query: -.. doctest:: - :options: +NORMALIZE_WHITESPACE +.. code-block:: pycon >>> list(q(PluginApp)) [(, ), @@ -1050,7 +1055,7 @@ We can now run the query: We can also filter the query for attributes of the action: -.. doctest:: +.. code-block:: pycon >>> list(q.filter(name='a')(PluginApp)) [(, )] @@ -1066,7 +1071,7 @@ own comparison function for an attribute using If you want to allow a query on a :class:`Composite` action you need to give it some help by defining -xs:attr:`dectate.Composite.query_classes`. +:attr:`dectate.Composite.query_classes`. .. _query_tool: @@ -1161,7 +1166,7 @@ install a Sphinx extension so that directives are documented properly. In your Sphinx ``conf.py`` add ``'dectate.sphinxext'`` to the ``extensions`` list. -.. _Sphinx: http://www.sphinx-doc.org +.. _Sphinx: https://sphinx-doc.org ``__main__`` and conflicts -------------------------- @@ -1213,12 +1218,12 @@ imports a module *twice* (`more about this`_). Dectate refuses to operate in this case until you change your imports so that this doesn't happen anymore. -.. _`more about this`: http://python-notes.curiousefficiency.org/en/latest/python_concepts/import_traps.html#executing-the-main-module-twice +.. _`more about this`: https://python-notes.curiousefficiency.org/en/latest/python_concepts/import_traps.html#executing-the-main-module-twice How to avoid this scenario? If you use setuptools `automatic script creation`_ this problem is avoided entirely. -.. _`automatic script creation`: https://pythonhosted.org/setuptools/setuptools.html#automatic-script-creation +.. _`automatic script creation`: https://setuptools.pypa.io/en/latest/userguide/entry_point.html .. sidebar:: Fooling Dectate after all diff --git a/pyproject.toml b/pyproject.toml index 22423fc..619fe40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dectate" -version = "0.15.dev0" +version = "1.0.0.dev0" dynamic = ["readme"] description = "A configuration engine for Python frameworks" license = "BSD-3-Clause" @@ -15,12 +15,14 @@ keywords = ["configuration"] classifiers = [ "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Application Frameworks", + "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Development Status :: 5 - Production/Stable", ] @@ -35,8 +37,14 @@ Changelog = "https://github.com/morepath/dectate/blob/master/CHANGES.txt" [project.optional-dependencies] test = ["pytest >= 8", "pytest-env"] coverage = ["pytest-cov"] -lint = ["black", "flake8", "flake8-pyproject"] -docs = ["sphinx"] +lint = [ + "black", + "flake8", + "flake8-pyproject", + "flake8-type-checking", + "isort", +] +docs = ["sphinx < 8.2"] mypy = ["mypy", "pytest"] pyright = ["pyright", "pytest"] @@ -65,42 +73,33 @@ show_missing = true [tool.flake8] show-source = true ignore = ["E203", "E301", "E501", "E704", "W503", "W504"] -max-line-length = 80 +extend-select = ["TC", "TC1"] +max-line-length = 88 [tool.mypy] python_version = "3.10" strict = true +files = ["."] warn_unreachable = true +warn_unused_ignores = true [[tool.mypy.overrides]] -module = "dectate.sphinxext.*" -ignore_errors = true - -[[tool.mypy.overrides]] -module = "dectate.tests.fixtures.*" +module = "dectate.tests.fixtures.anapp" ignore_errors = true [tool.pyright] +typeCheckingMode = "strict" exclude = [ - "**/sphinxext.py", "**/tests/fixtures/anapp.py", + "__pycache__", + ".venv", + "**/.*", + "**/node_modules", ] - -[tool.black] -line-length = 80 -target-version = ['py310', 'py311', 'py312', 'py313', 'py314'] -include = '\.pyi?$' -exclude = ''' -( - /( - \.git - | \.tox - | env - | build - | dist - )/ -) -''' +reportPrivateUsage = "none" +reportUnusedClass = "none" +reportUnusedFunction = "none" +reportUntypedFunctionDecorator = "none" [tool.tox] requires = ["tox>=4"] @@ -120,11 +119,11 @@ env_list = [ skip_missing_interpreters = true [tool.tox.gh.python] -"3.10" = ["py310", "mypy", "pyright"] +"3.10" = ["py310"] "3.11" = ["py311"] "3.12" = ["py312"] "3.13" = ["py313"] -"3.14" = ["py314", "pre-commit", "coverage"] +"3.14" = ["py314", "pre-commit", "coverage", "mypy", "pyright"] "pypy-3.11" = ["pypy3"] [tool.tox.env_run_base] @@ -136,7 +135,7 @@ commands = [["pytest", "{posargs:dectate}"]] base_python = ["python3"] extras = ["test", "coverage"] commands = [ - ["pytest", "--cov", "--cov-fail-under=94", "{posargs:dectate}"], + ["pytest", "--cov", "--cov-fail-under=100", "{posargs:dectate}"], ] [tool.tox.env.pre-commit] @@ -156,22 +155,47 @@ commands = [ [tool.tox.env.mypy] base_python = ["python3"] -extras = ["mypy"] +extras = ["mypy", "docs"] commands = [ - ["mypy", "-p", "dectate", "--python-version", "3.10"], - ["mypy", "-p", "dectate", "--python-version", "3.11"], - ["mypy", "-p", "dectate", "--python-version", "3.12"], - ["mypy", "-p", "dectate", "--python-version", "3.13"], - ["mypy", "-p", "dectate", "--python-version", "3.14"], + ["mypy", "--python-version", "3.10"], + ["mypy", "--python-version", "3.11"], + ["mypy", "--python-version", "3.12"], + ["mypy", "--python-version", "3.13"], + ["mypy", "--python-version", "3.14"], ] [tool.tox.env.pyright] base_python = ["python3"] -extras = ["pyright"] +extras = ["pyright", "docs"] commands = [ - ["pyright", "dectate", "--pythonversion", "3.10"], - ["pyright", "dectate", "--pythonversion", "3.11"], - ["pyright", "dectate", "--pythonversion", "3.12"], - ["pyright", "dectate", "--pythonversion", "3.13"], - ["pyright", "dectate", "--pythonversion", "3.14"], + ["pyright", "--pythonversion", "3.10"], + ["pyright", "--pythonversion", "3.11"], + ["pyright", "--pythonversion", "3.12"], + ["pyright", "--pythonversion", "3.13"], + ["pyright", "--pythonversion", "3.14"], ] + +[tool.black] +target-version = ['py310', 'py311', 'py312', 'py313', 'py314'] +include = '\.pyi?$' +exclude = ''' +( + /( + \.git + | \.tox + | env + | venv + | .venv + | __pycache__ + | build + | dist + )/ +) +''' + +[tool.isort] +profile = 'black' +py_version = 310 +skip_gitignore = true +extra_standard_library = ["typing_extensions"] +known_first_party = ["dectate", "importscan", "reg"] diff --git a/scenarios/main_module/app.py b/scenarios/main_module/app.py index cc023ce..57e2129 100644 --- a/scenarios/main_module/app.py +++ b/scenarios/main_module/app.py @@ -1,14 +1,16 @@ -from config import App import pprint + +import app2 # pyright: ignore[reportUnusedImport] # noqa: F401 +from config import App as App + import dectate -import app2 # noqa @App.foo(name="a") -def f(): +def f() -> None: pass if __name__ == "__main__": - dectate.commit([App]) + dectate.commit(App) pprint.pprint(App.config.my) diff --git a/scenarios/main_module/app2.py b/scenarios/main_module/app2.py index 692bd58..80f3789 100644 --- a/scenarios/main_module/app2.py +++ b/scenarios/main_module/app2.py @@ -2,5 +2,5 @@ @app.App.foo(name="b") -def g(): +def g() -> None: pass diff --git a/scenarios/main_module/config.py b/scenarios/main_module/config.py index 3563065..bb07f42 100644 --- a/scenarios/main_module/config.py +++ b/scenarios/main_module/config.py @@ -1,19 +1,25 @@ -import dectate +from __future__ import annotations +from typing import TYPE_CHECKING, Any, ClassVar -class App(dectate.App): - pass +import dectate + +if TYPE_CHECKING: + from collections.abc import Callable -@App.directive("foo") class FooAction(dectate.Action): - config = {"my": list} + config: ClassVar[dict[str, Callable[..., Any]]] = {"my": list} - def __init__(self, name): + def __init__(self, name: str) -> None: self.name = name - def identifier(self, my): + def identifier(self, my: list[tuple[str, object]]) -> str: return self.name - def perform(self, obj, my): + def perform(self, obj: object, my: list[tuple[str, object]]) -> None: my.append((self.name, obj)) + + +class App(dectate.App): + foo = dectate.directive(FooAction) diff --git a/scenarios/query/query/a.py b/scenarios/query/query/a.py index 6a21c07..28c03a8 100644 --- a/scenarios/query/query/a.py +++ b/scenarios/query/query/a.py @@ -1,37 +1,24 @@ import dectate -class App(dectate.App): - pass - - -class Other(dectate.App): - pass - - class R: pass -@App.directive("foo") class FooAction(dectate.Action): - def __init__(self, name): + def __init__(self, name: str) -> None: self.name = name - def identifier(self): + def identifier(self) -> str: return self.name - def perform(self, obj): + def perform(self, obj: R) -> None: pass -@Other.directive("foo") -class OtherFooAction(dectate.Action): - def __init__(self, name): - self.name = name +class App(dectate.App): + foo = dectate.directive(FooAction) - def identifier(self): - return self.name - def perform(self, obj): - pass +class Other(dectate.App): + foo = dectate.directive(FooAction) diff --git a/scenarios/query/query/b.py b/scenarios/query/query/b.py index e883a1e..c734cd4 100644 --- a/scenarios/query/query/b.py +++ b/scenarios/query/query/b.py @@ -2,20 +2,20 @@ @App.foo(name="alpha") -def f(): +def f() -> None: pass @App.foo(name="beta") -def g(): +def g() -> None: pass @App.foo(name="gamma") -def h(): +def h() -> None: pass @Other.foo(name="alpha") -def i(): +def i() -> None: pass diff --git a/scenarios/query/query/c.py b/scenarios/query/query/c.py index 2363c95..2e06f7c 100644 --- a/scenarios/query/query/c.py +++ b/scenarios/query/query/c.py @@ -2,5 +2,5 @@ @App.foo(name="lah") -def x(): +def x() -> None: pass diff --git a/scenarios/query/query/main.py b/scenarios/query/query/main.py index 82f8917..0490454 100644 --- a/scenarios/query/query/main.py +++ b/scenarios/query/query/main.py @@ -1,7 +1,8 @@ import dectate -from . import a, b, c # noqa F401 +from . import a, b, c # pyright: ignore[reportUnusedImport] # noqa: F401 -def query_tool(): + +def query_tool() -> None: dectate.commit(a.App, a.Other) dectate.query_tool([a.App, a.Other]) diff --git a/scenarios/query/setup.py b/scenarios/query/setup.py index afd069d..eeacbdf 100644 --- a/scenarios/query/setup.py +++ b/scenarios/query/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup # type: ignore[import-untyped] setup( name="query",