├── jaraco └── classes │ ├── py.typed │ ├── __init__.py │ ├── ancestry.py │ ├── meta.py │ └── properties.py ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── towncrier.toml ├── docs ├── history.rst ├── index.rst └── conf.py ├── .pre-commit-config.yaml ├── SECURITY.md ├── .editorconfig ├── .coveragerc ├── mypy.ini ├── .readthedocs.yaml ├── pytest.ini ├── ruff.toml ├── README.rst ├── tox.ini ├── pyproject.toml └── NEWS.rst /jaraco/classes/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jaraco/classes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/jaraco.classes 2 | -------------------------------------------------------------------------------- /towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | title_format = "{version}" 3 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | .. _changes: 4 | 5 | History 6 | ******* 7 | 8 | .. include:: ../NEWS (links).rst 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.9.9 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --unsafe-fixes] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Contact 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.py] 11 | indent_style = space 12 | max_line_length = 88 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.rst] 19 | indent_style = space 20 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # leading `*/` for pytest-dev/pytest-cov#456 4 | */.tox/* 5 | disable_warnings = 6 | couldnt-parse 7 | 8 | [report] 9 | show_missing = True 10 | exclude_also = 11 | # Exclude common false positives per 12 | # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion 13 | # Ref jaraco/skeleton#97 and jaraco/skeleton#135 14 | class .*\bProtocol\): 15 | if TYPE_CHECKING: 16 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Is the project well-typed? 3 | strict = False 4 | 5 | # Early opt-in even when strict = False 6 | warn_unused_ignores = True 7 | warn_redundant_casts = True 8 | enable_error_code = ignore-without-code 9 | 10 | # Support namespace packages per https://github.com/python/mypy/issues/14057 11 | explicit_package_bases = True 12 | 13 | disable_error_code = 14 | # Disable due to many false positives 15 | overload-overlap, 16 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - path: . 5 | extra_requirements: 6 | - doc 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # required boilerplate readthedocs/readthedocs.org#10401 12 | build: 13 | os: ubuntu-lts-latest 14 | tools: 15 | python: latest 16 | # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 17 | jobs: 18 | post_checkout: 19 | - git fetch --unshallow || true 20 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build .tox .eggs 3 | addopts= 4 | --doctest-modules 5 | --import-mode importlib 6 | consider_namespace_packages=true 7 | filterwarnings= 8 | ## upstream 9 | 10 | # Ensure ResourceWarnings are emitted 11 | default::ResourceWarning 12 | 13 | # realpython/pytest-mypy#152 14 | ignore:'encoding' argument not specified::pytest_mypy 15 | 16 | # python/cpython#100750 17 | ignore:'encoding' argument not specified::platform 18 | 19 | # pypa/build#615 20 | ignore:'encoding' argument not specified::build.env 21 | 22 | # dateutil/dateutil#1284 23 | ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz 24 | 25 | ## end upstream 26 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to |project| documentation! 2 | =================================== 3 | 4 | .. sidebar-links:: 5 | :home: 6 | :pypi: 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | 11 | history 12 | 13 | 14 | .. tidelift-referral-banner:: 15 | 16 | .. automodule:: jaraco.classes.ancestry 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | 21 | .. automodule:: jaraco.classes.meta 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | 26 | .. automodule:: jaraco.classes.properties 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | 32 | Indices and tables 33 | ================== 34 | 35 | * :ref:`genindex` 36 | * :ref:`modindex` 37 | * :ref:`search` 38 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | extend-select = [ 3 | # upstream 4 | 5 | "C901", # complex-structure 6 | "I", # isort 7 | "PERF401", # manual-list-comprehension 8 | 9 | # Ensure modern type annotation syntax and best practices 10 | # Not including those covered by type-checkers or exclusive to Python 3.11+ 11 | "FA", # flake8-future-annotations 12 | "F404", # late-future-import 13 | "PYI", # flake8-pyi 14 | "UP006", # non-pep585-annotation 15 | "UP007", # non-pep604-annotation 16 | "UP010", # unnecessary-future-import 17 | "UP035", # deprecated-import 18 | "UP037", # quoted-annotation 19 | "UP043", # unnecessary-default-type-args 20 | 21 | # local 22 | ] 23 | ignore = [ 24 | # upstream 25 | 26 | # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, 27 | # irrelevant to this project. 28 | "PYI011", # typed-argument-default-in-stub 29 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 30 | "W191", 31 | "E111", 32 | "E114", 33 | "E117", 34 | "D206", 35 | "D300", 36 | "Q000", 37 | "Q001", 38 | "Q002", 39 | "Q003", 40 | "COM812", 41 | "COM819", 42 | 43 | # local 44 | ] 45 | 46 | [format] 47 | # Enable preview to get hugged parenthesis unwrapping and other nice surprises 48 | # See https://github.com/jaraco/skeleton/pull/133#issuecomment-2239538373 49 | preview = true 50 | # https://docs.astral.sh/ruff/settings/#format_quote-style 51 | quote-style = "preserve" 52 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/jaraco.classes.svg 2 | :target: https://pypi.org/project/jaraco.classes 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/jaraco.classes.svg 5 | 6 | .. image:: https://github.com/jaraco/jaraco.classes/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/jaraco/jaraco.classes/actions?query=workflow%3A%22tests%22 8 | :alt: tests 9 | 10 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 11 | :target: https://github.com/astral-sh/ruff 12 | :alt: Ruff 13 | 14 | .. image:: https://readthedocs.org/projects/jaracoclasses/badge/?version=latest 15 | :target: https://jaracoclasses.readthedocs.io/en/latest/?badge=latest 16 | 17 | .. image:: https://img.shields.io/badge/skeleton-2025-informational 18 | :target: https://blog.jaraco.com/skeleton 19 | 20 | .. image:: https://tidelift.com/badges/package/pypi/jaraco.classes 21 | :target: https://tidelift.com/subscription/pkg/pypi-jaraco.classes?utm_source=pypi-jaraco.classes&utm_medium=readme 22 | 23 | For Enterprise 24 | ============== 25 | 26 | Available as part of the Tidelift Subscription. 27 | 28 | This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. 29 | 30 | `Learn more `_. 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | description = perform primary checks (tests, style, types, coverage) 3 | deps = 4 | setenv = 5 | PYTHONWARNDEFAULTENCODING = 1 6 | commands = 7 | pytest {posargs} 8 | usedevelop = True 9 | extras = 10 | test 11 | check 12 | cover 13 | enabler 14 | type 15 | 16 | [testenv:diffcov] 17 | description = run tests and check that diff from main is covered 18 | deps = 19 | {[testenv]deps} 20 | diff-cover 21 | commands = 22 | pytest {posargs} --cov-report xml 23 | diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html 24 | diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 25 | 26 | [testenv:docs] 27 | description = build the documentation 28 | extras = 29 | doc 30 | test 31 | changedir = docs 32 | commands = 33 | python -m sphinx -W --keep-going . {toxinidir}/build/html 34 | python -m sphinxlint 35 | 36 | [testenv:finalize] 37 | description = assemble changelog and tag a release 38 | skip_install = True 39 | deps = 40 | towncrier 41 | jaraco.develop >= 7.23 42 | pass_env = * 43 | commands = 44 | python -m jaraco.develop.finalize 45 | 46 | 47 | [testenv:release] 48 | description = publish the package to PyPI and GitHub 49 | skip_install = True 50 | deps = 51 | build 52 | twine>=3 53 | jaraco.develop>=7.1 54 | pass_env = 55 | TWINE_PASSWORD 56 | GITHUB_TOKEN 57 | setenv = 58 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 59 | commands = 60 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 61 | python -m build 62 | python -m twine upload dist/* 63 | python -m jaraco.develop.create-github-release 64 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=77", 4 | "setuptools_scm[toml]>=3.4.1", 5 | # jaraco/skeleton#174 6 | "coherent.licensed", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [project] 11 | name = "jaraco.classes" 12 | authors = [ 13 | { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, 14 | ] 15 | description = "Utility functions for Python class constructs" 16 | readme = "README.rst" 17 | classifiers = [ 18 | "Development Status :: 5 - Production/Stable", 19 | "Intended Audience :: Developers", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3 :: Only", 22 | ] 23 | requires-python = ">=3.9" 24 | license = "MIT" 25 | dependencies = [ 26 | "more_itertools", 27 | ] 28 | dynamic = ["version"] 29 | 30 | [project.urls] 31 | Source = "https://github.com/jaraco/jaraco.classes" 32 | 33 | [project.optional-dependencies] 34 | test = [ 35 | # upstream 36 | "pytest >= 6, != 8.1.*", 37 | 38 | # local 39 | ] 40 | 41 | doc = [ 42 | # upstream 43 | "sphinx >= 3.5", 44 | "jaraco.packaging >= 9.3", 45 | "rst.linker >= 1.9", 46 | "furo", 47 | "sphinx-lint", 48 | 49 | # tidelift 50 | "jaraco.tidelift >= 1.4", 51 | 52 | # local 53 | ] 54 | 55 | check = [ 56 | "pytest-checkdocs >= 2.4", 57 | "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", 58 | ] 59 | 60 | cover = [ 61 | "pytest-cov", 62 | ] 63 | 64 | enabler = [ 65 | "pytest-enabler >= 2.2", 66 | ] 67 | 68 | type = [ 69 | # upstream 70 | "pytest-mypy", 71 | 72 | # local 73 | ] 74 | 75 | 76 | [tool.setuptools_scm] 77 | 78 | 79 | [tool.pytest-enabler.mypy] 80 | # Disabled due to jaraco/skeleton#143 81 | -------------------------------------------------------------------------------- /jaraco/classes/ancestry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Routines for obtaining the class names 3 | of an object and its parent classes. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from typing import TYPE_CHECKING, cast 9 | 10 | from more_itertools import unique_everseen 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Iterator 14 | from typing import Any 15 | 16 | 17 | def all_bases(c: type[object]) -> list[type[Any]]: 18 | """ 19 | return a tuple of all base classes the class c has as a parent. 20 | >>> object in all_bases(list) 21 | True 22 | """ 23 | return c.mro()[1:] 24 | 25 | 26 | def all_classes(c: type[object]) -> list[type[Any]]: 27 | """ 28 | return a tuple of all classes to which c belongs 29 | >>> list in all_classes(list) 30 | True 31 | """ 32 | return c.mro() 33 | 34 | 35 | # borrowed from 36 | # http://code.activestate.com/recipes/576949-find-all-subclasses-of-a-given-class/ 37 | 38 | 39 | def iter_subclasses(cls: type[object]) -> Iterator[type[Any]]: 40 | """ 41 | Generator over all subclasses of a given class, in depth-first order. 42 | 43 | >>> bool in list(iter_subclasses(int)) 44 | True 45 | >>> class A(object): pass 46 | >>> class B(A): pass 47 | >>> class C(A): pass 48 | >>> class D(B,C): pass 49 | >>> class E(D): pass 50 | >>> 51 | >>> for cls in iter_subclasses(A): 52 | ... print(cls.__name__) 53 | B 54 | D 55 | E 56 | C 57 | >>> # get ALL classes currently defined 58 | >>> res = [cls.__name__ for cls in iter_subclasses(object)] 59 | >>> 'type' in res 60 | True 61 | >>> 'tuple' in res 62 | True 63 | >>> len(res) > 100 64 | True 65 | """ 66 | return unique_everseen(_iter_all_subclasses(cls)) 67 | 68 | 69 | def _iter_all_subclasses(cls: type[object]) -> Iterator[type[Any]]: 70 | try: 71 | subs = cls.__subclasses__() 72 | except TypeError: # fails only when cls is type 73 | subs = cast('type[type]', cls).__subclasses__(cls) 74 | for sub in subs: 75 | yield sub 76 | yield from iter_subclasses(sub) 77 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'jaraco.packaging.sphinx', 6 | ] 7 | 8 | master_doc = "index" 9 | html_theme = "furo" 10 | 11 | # Link dates and other references in the changelog 12 | extensions += ['rst.linker'] 13 | link_files = { 14 | '../NEWS.rst': dict( 15 | using=dict(GH='https://github.com'), 16 | replace=[ 17 | dict( 18 | pattern=r'(Issue #|\B#)(?P\d+)', 19 | url='{package_url}/issues/{issue}', 20 | ), 21 | dict( 22 | pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', 23 | with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', 24 | ), 25 | dict( 26 | pattern=r'PEP[- ](?P\d+)', 27 | url='https://peps.python.org/pep-{pep_number:0>4}/', 28 | ), 29 | ], 30 | ) 31 | } 32 | 33 | # Be strict about any broken references 34 | nitpicky = True 35 | nitpick_ignore: list[tuple[str, str]] = [] 36 | 37 | # Include Python intersphinx mapping to prevent failures 38 | # jaraco/skeleton#51 39 | extensions += ['sphinx.ext.intersphinx'] 40 | intersphinx_mapping = { 41 | 'python': ('https://docs.python.org/3', None), 42 | } 43 | 44 | # Preserve authored syntax for defaults 45 | autodoc_preserve_defaults = True 46 | 47 | # Add support for linking usernames, PyPI projects, Wikipedia pages 48 | github_url = 'https://github.com/' 49 | extlinks = { 50 | 'user': (f'{github_url}%s', '@%s'), 51 | 'pypi': ('https://pypi.org/project/%s', '%s'), 52 | 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), 53 | } 54 | extensions += ['sphinx.ext.extlinks'] 55 | 56 | # local 57 | 58 | extensions += ['jaraco.tidelift'] 59 | 60 | nitpick_ignore += [ 61 | ('py:class', 'Self'), 62 | ('py:class', '_T'), 63 | ('py:class', '_U'), 64 | ('py:obj', 'jaraco.classes.properties._T'), 65 | ('py:obj', 'jaraco.classes.properties._U'), 66 | ('py:class', '_ClassPropertyAttribute'), 67 | ('py:class', '_GetterCallable'), 68 | ('py:class', '_GetterClassMethod'), 69 | ('py:class', '_SetterCallable'), 70 | ('py:class', '_SetterClassMethod'), 71 | ] 72 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | v3.4.0 2 | ====== 3 | 4 | Features 5 | -------- 6 | 7 | - Better type hints for NonDataProperty. (#13) 8 | 9 | 10 | v3.3.1 11 | ====== 12 | 13 | No significant changes. 14 | 15 | 16 | v3.3.0 17 | ====== 18 | 19 | Features 20 | -------- 21 | 22 | - Require Python 3.8 or later. 23 | 24 | 25 | v3.2.3 26 | ====== 27 | 28 | #7: Enabled badge with link to docs in the readme. 29 | 30 | v3.2.2 31 | ====== 32 | 33 | Refreshed package metadata. 34 | 35 | Enrolled with Tidelift. 36 | 37 | v3.2.1 38 | ====== 39 | 40 | Refreshed package metadata. 41 | 42 | v3.2.0 43 | ====== 44 | 45 | Switched to native namespace for jaraco package. 46 | 47 | v3.1.1 48 | ====== 49 | 50 | Packaging refresh and associated cleanups, including fix 51 | for #4 (failing black check). 52 | 53 | v3.1.0 54 | ====== 55 | 56 | ``classproperty`` decorator now supplies a 57 | ``classproperty.Meta`` class. Classes that wish to have 58 | a class property should derive from that metaclass. This 59 | approach solves the unintended behavior of the property 60 | only being set on a given instance. For compatibility, the 61 | old behavior is retained if the metaclass is not used. 62 | 63 | v3.0.0 64 | ====== 65 | 66 | Project now requires Python 3.6 or later. 67 | 68 | 2.0 69 | === 70 | 71 | Switch to `pkgutil namespace technique 72 | `_ 73 | for the ``jaraco`` namespace. 74 | 75 | 1.5 76 | === 77 | 78 | Refresh packaging. 79 | 80 | Use Python 3 syntax for new-style classes. 81 | 82 | 1.4.3 83 | ===== 84 | 85 | Corrected namespace package declaration to match 86 | ``jaraco`` namespaced packages. 87 | 88 | 1.4.2 89 | ===== 90 | 91 | #1: Added a project description. 92 | 93 | 1.4.1 94 | ===== 95 | 96 | Refresh packaging. 97 | 98 | 1.4 99 | === 100 | 101 | Added documentation. 102 | 103 | Project is now automatically released by Travis CI. 104 | 105 | 1.3 106 | === 107 | 108 | Move hosting to Github. 109 | 110 | Use setuptools_scm for version detection. 111 | 112 | 1.2 113 | === 114 | 115 | Limit dependencies in setup_requires. 116 | 117 | 1.1 118 | === 119 | 120 | Added ``properties`` module from jaraco.util 10.8. 121 | 122 | 1.0 123 | === 124 | 125 | Initial release based on jaraco.util 10.8. 126 | -------------------------------------------------------------------------------- /jaraco/classes/meta.py: -------------------------------------------------------------------------------- 1 | """ 2 | meta.py 3 | 4 | Some useful metaclasses. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from typing import TYPE_CHECKING 10 | 11 | if TYPE_CHECKING: 12 | from typing import Any 13 | 14 | 15 | class LeafClassesMeta(type): 16 | """ 17 | A metaclass for classes that keeps track of all of them that 18 | aren't base classes. 19 | 20 | >>> Parent = LeafClassesMeta('MyParentClass', (), {}) 21 | >>> Parent in Parent._leaf_classes 22 | True 23 | >>> Child = LeafClassesMeta('MyChildClass', (Parent,), {}) 24 | >>> Child in Parent._leaf_classes 25 | True 26 | >>> Parent in Parent._leaf_classes 27 | False 28 | 29 | >>> Other = LeafClassesMeta('OtherClass', (), {}) 30 | >>> Parent in Other._leaf_classes 31 | False 32 | >>> len(Other._leaf_classes) 33 | 1 34 | """ 35 | 36 | _leaf_classes: set[type[Any]] 37 | 38 | def __init__( 39 | cls, 40 | name: str, 41 | bases: tuple[type[object], ...], 42 | attrs: dict[str, object], 43 | ) -> None: 44 | if not hasattr(cls, '_leaf_classes'): 45 | cls._leaf_classes = set() 46 | leaf_classes = getattr(cls, '_leaf_classes') 47 | leaf_classes.add(cls) 48 | # remove any base classes 49 | leaf_classes -= set(bases) 50 | 51 | 52 | class TagRegistered(type): 53 | """ 54 | As classes of this metaclass are created, they keep a registry in the 55 | base class of all classes by a class attribute, indicated by attr_name. 56 | 57 | >>> FooObject = TagRegistered('FooObject', (), dict(tag='foo')) 58 | >>> FooObject._registry['foo'] is FooObject 59 | True 60 | >>> BarObject = TagRegistered('Barobject', (FooObject,), dict(tag='bar')) 61 | >>> FooObject._registry is BarObject._registry 62 | True 63 | >>> len(FooObject._registry) 64 | 2 65 | 66 | '...' below should be 'jaraco.classes' but for pytest-dev/pytest#3396 67 | >>> FooObject._registry['bar'] 68 | 69 | """ 70 | 71 | attr_name = 'tag' 72 | 73 | def __init__( 74 | cls, 75 | name: str, 76 | bases: tuple[type[object], ...], 77 | namespace: dict[str, object], 78 | ) -> None: 79 | super(TagRegistered, cls).__init__(name, bases, namespace) 80 | if not hasattr(cls, '_registry'): 81 | cls._registry = {} 82 | meta = cls.__class__ 83 | attr = getattr(cls, meta.attr_name, None) 84 | if attr: 85 | cls._registry[attr] = cls 86 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | merge_group: 5 | push: 6 | branches-ignore: 7 | # temporary GH branches relating to merge queues (jaraco/skeleton#93) 8 | - gh-readonly-queue/** 9 | tags: 10 | # required if branches-ignore is supplied (jaraco/skeleton#103) 11 | - '**' 12 | pull_request: 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | # Environment variable to support color support (jaraco/skeleton#66) 20 | FORCE_COLOR: 1 21 | 22 | # Suppress noisy pip warnings 23 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 24 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 25 | 26 | # Ensure tests can sense settings about the environment 27 | TOX_OVERRIDE: >- 28 | testenv.pass_env+=GITHUB_*,FORCE_COLOR 29 | 30 | 31 | jobs: 32 | test: 33 | strategy: 34 | # https://blog.jaraco.com/efficient-use-of-ci-resources/ 35 | matrix: 36 | python: 37 | - "3.9" 38 | - "3.13" 39 | platform: 40 | - ubuntu-latest 41 | - macos-latest 42 | - windows-latest 43 | include: 44 | - python: "3.10" 45 | platform: ubuntu-latest 46 | - python: "3.11" 47 | platform: ubuntu-latest 48 | - python: "3.12" 49 | platform: ubuntu-latest 50 | - python: "3.14" 51 | platform: ubuntu-latest 52 | - python: pypy3.10 53 | platform: ubuntu-latest 54 | runs-on: ${{ matrix.platform }} 55 | continue-on-error: ${{ matrix.python == '3.14' }} 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Install build dependencies 59 | # Install dependencies for building packages on pre-release Pythons 60 | # jaraco/skeleton#161 61 | if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' 62 | run: | 63 | sudo apt update 64 | sudo apt install -y libxml2-dev libxslt-dev 65 | - name: Setup Python 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: ${{ matrix.python }} 69 | allow-prereleases: true 70 | - name: Install tox 71 | run: python -m pip install tox 72 | - name: Run 73 | run: tox 74 | 75 | collateral: 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | job: 80 | - diffcov 81 | - docs 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | fetch-depth: 0 87 | - name: Setup Python 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: 3.x 91 | - name: Install tox 92 | run: python -m pip install tox 93 | - name: Eval ${{ matrix.job }} 94 | run: tox -e ${{ matrix.job }} 95 | 96 | check: # This job does nothing and is only used for the branch protection 97 | if: always() 98 | 99 | needs: 100 | - test 101 | - collateral 102 | 103 | runs-on: ubuntu-latest 104 | 105 | steps: 106 | - name: Decide whether the needed jobs succeeded or failed 107 | uses: re-actors/alls-green@release/v1 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | 111 | release: 112 | permissions: 113 | contents: write 114 | needs: 115 | - check 116 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 117 | runs-on: ubuntu-latest 118 | 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Setup Python 122 | uses: actions/setup-python@v5 123 | with: 124 | python-version: 3.x 125 | - name: Install tox 126 | run: python -m pip install tox 127 | - name: Run 128 | run: tox -e release 129 | env: 130 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | -------------------------------------------------------------------------------- /jaraco/classes/properties.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Generic, TypeVar, cast, overload 4 | 5 | _T = TypeVar('_T') 6 | _U = TypeVar('_U') 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Callable 10 | from typing import Any, Protocol 11 | 12 | from typing_extensions import Self, TypeAlias 13 | 14 | # TODO(coherent-oss/granary#4): Migrate to PEP 695 by 2027-10. 15 | _GetterCallable: TypeAlias = Callable[..., _T] 16 | _GetterClassMethod: TypeAlias = classmethod[Any, [], _T] 17 | 18 | _SetterCallable: TypeAlias = Callable[[type[Any], _T], None] 19 | _SetterClassMethod: TypeAlias = classmethod[Any, [_T], None] 20 | 21 | class _ClassPropertyAttribute(Protocol[_T]): 22 | def __get__(self, obj: object, objtype: type[Any] | None = None) -> _T: ... 23 | 24 | def __set__(self, obj: object, value: _T) -> None: ... 25 | 26 | 27 | class NonDataProperty(Generic[_T, _U]): 28 | """Much like the property builtin, but only implements __get__, 29 | making it a non-data property, and can be subsequently reset. 30 | 31 | See http://users.rcn.com/python/download/Descriptor.htm for more 32 | information. 33 | 34 | >>> class X(object): 35 | ... @NonDataProperty 36 | ... def foo(self): 37 | ... return 3 38 | >>> x = X() 39 | >>> x.foo 40 | 3 41 | >>> x.foo = 4 42 | >>> x.foo 43 | 4 44 | 45 | '...' below should be 'jaraco.classes' but for pytest-dev/pytest#3396 46 | >>> X.foo 47 | <....properties.NonDataProperty object at ...> 48 | """ 49 | 50 | def __init__(self, fget: Callable[[_T], _U]) -> None: 51 | assert fget is not None, "fget cannot be none" 52 | assert callable(fget), "fget must be callable" 53 | self.fget = fget 54 | 55 | @overload 56 | def __get__( 57 | self, 58 | obj: None, 59 | objtype: None, 60 | ) -> Self: ... 61 | 62 | @overload 63 | def __get__( 64 | self, 65 | obj: _T, 66 | objtype: type[_T] | None = None, 67 | ) -> _U: ... 68 | 69 | def __get__( 70 | self, 71 | obj: _T | None, 72 | objtype: type[_T] | None = None, 73 | ) -> Self | _U: 74 | if obj is None: 75 | return self 76 | return self.fget(obj) 77 | 78 | 79 | class classproperty(Generic[_T]): 80 | """ 81 | Like @property but applies at the class level. 82 | 83 | 84 | >>> class X(metaclass=classproperty.Meta): 85 | ... val = None 86 | ... @classproperty 87 | ... def foo(cls): 88 | ... return cls.val 89 | ... @foo.setter 90 | ... def foo(cls, val): 91 | ... cls.val = val 92 | >>> X.foo 93 | >>> X.foo = 3 94 | >>> X.foo 95 | 3 96 | >>> x = X() 97 | >>> x.foo 98 | 3 99 | >>> X.foo = 4 100 | >>> x.foo 101 | 4 102 | 103 | Setting the property on an instance affects the class. 104 | 105 | >>> x.foo = 5 106 | >>> x.foo 107 | 5 108 | >>> X.foo 109 | 5 110 | >>> vars(x) 111 | {} 112 | >>> X().foo 113 | 5 114 | 115 | Attempting to set an attribute where no setter was defined 116 | results in an AttributeError: 117 | 118 | >>> class GetOnly(metaclass=classproperty.Meta): 119 | ... @classproperty 120 | ... def foo(cls): 121 | ... return 'bar' 122 | >>> GetOnly.foo = 3 123 | Traceback (most recent call last): 124 | ... 125 | AttributeError: can't set attribute 126 | 127 | It is also possible to wrap a classmethod or staticmethod in 128 | a classproperty. 129 | 130 | >>> class Static(metaclass=classproperty.Meta): 131 | ... @classproperty 132 | ... @classmethod 133 | ... def foo(cls): 134 | ... return 'foo' 135 | ... @classproperty 136 | ... @staticmethod 137 | ... def bar(): 138 | ... return 'bar' 139 | >>> Static.foo 140 | 'foo' 141 | >>> Static.bar 142 | 'bar' 143 | 144 | *Legacy* 145 | 146 | For compatibility, if the metaclass isn't specified, the 147 | legacy behavior will be invoked. 148 | 149 | >>> class X: 150 | ... val = None 151 | ... @classproperty 152 | ... def foo(cls): 153 | ... return cls.val 154 | ... @foo.setter 155 | ... def foo(cls, val): 156 | ... cls.val = val 157 | >>> X.foo 158 | >>> X.foo = 3 159 | >>> X.foo 160 | 3 161 | >>> x = X() 162 | >>> x.foo 163 | 3 164 | >>> X.foo = 4 165 | >>> x.foo 166 | 4 167 | 168 | Note, because the metaclass was not specified, setting 169 | a value on an instance does not have the intended effect. 170 | 171 | >>> x.foo = 5 172 | >>> x.foo 173 | 5 174 | >>> X.foo # should be 5 175 | 4 176 | >>> vars(x) # should be empty 177 | {'foo': 5} 178 | >>> X().foo # should be 5 179 | 4 180 | """ 181 | 182 | fget: _ClassPropertyAttribute[_GetterClassMethod[_T]] 183 | fset: _ClassPropertyAttribute[_SetterClassMethod[_T] | None] 184 | 185 | class Meta(type): 186 | def __setattr__(self, key: str, value: object) -> None: 187 | obj = self.__dict__.get(key, None) 188 | if type(obj) is classproperty: 189 | return obj.__set__(self, value) 190 | return super().__setattr__(key, value) 191 | 192 | def __init__( 193 | self, 194 | fget: _GetterCallable[_T] | _GetterClassMethod[_T], 195 | fset: _SetterCallable[_T] | _SetterClassMethod[_T] | None = None, 196 | ) -> None: 197 | self.fget = self._ensure_method(fget) 198 | self.fset = fset # type: ignore[assignment] # Corrected in the next line. 199 | fset and self.setter(fset) 200 | 201 | def __get__(self, instance: object, owner: type[object] | None = None) -> _T: 202 | return self.fget.__get__(None, owner)() 203 | 204 | def __set__(self, owner: object, value: _T) -> None: 205 | if not self.fset: 206 | raise AttributeError("can't set attribute") 207 | if type(owner) is not classproperty.Meta: 208 | owner = type(owner) 209 | return self.fset.__get__(None, cast('type[object]', owner))(value) 210 | 211 | def setter(self, fset: _SetterCallable[_T] | _SetterClassMethod[_T]) -> Self: 212 | self.fset = self._ensure_method(fset) 213 | return self 214 | 215 | @overload 216 | @classmethod 217 | def _ensure_method( 218 | cls, 219 | fn: _GetterCallable[_T] | _GetterClassMethod[_T], 220 | ) -> _GetterClassMethod[_T]: ... 221 | 222 | @overload 223 | @classmethod 224 | def _ensure_method( 225 | cls, 226 | fn: _SetterCallable[_T] | _SetterClassMethod[_T], 227 | ) -> _SetterClassMethod[_T]: ... 228 | 229 | @classmethod 230 | def _ensure_method( 231 | cls, 232 | fn: _GetterCallable[_T] 233 | | _GetterClassMethod[_T] 234 | | _SetterCallable[_T] 235 | | _SetterClassMethod[_T], 236 | ) -> _GetterClassMethod[_T] | _SetterClassMethod[_T]: 237 | """ 238 | Ensure fn is a classmethod or staticmethod. 239 | """ 240 | needs_method = not isinstance(fn, (classmethod, staticmethod)) 241 | return classmethod(fn) if needs_method else fn # type: ignore[arg-type,return-value] 242 | --------------------------------------------------------------------------------