├── jaraco └── collections │ ├── py.typed │ └── __init__.py ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── towncrier.toml ├── docs ├── history.rst ├── index.rst └── conf.py ├── .pre-commit-config.yaml ├── SECURITY.md ├── .editorconfig ├── .coveragerc ├── .readthedocs.yaml ├── mypy.ini ├── pytest.ini ├── tests └── test_collections.py ├── ruff.toml ├── pyproject.toml ├── tox.ini ├── README.rst └── NEWS.rst /jaraco/collections/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/jaraco.collections 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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.collections 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | 21 | 22 | Indices and tables 23 | ================== 24 | 25 | * :ref:`genindex` 26 | * :ref:`modindex` 27 | * :ref:`search` 28 | -------------------------------------------------------------------------------- /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 | 17 | # jaraco/jaraco.text#17 18 | [mypy-jaraco.text.*] 19 | ignore_missing_imports = 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 | 27 | ignore:DictFilter is deprecated 28 | -------------------------------------------------------------------------------- /tests/test_collections.py: -------------------------------------------------------------------------------- 1 | from jaraco import collections 2 | 3 | 4 | class AlwaysStringKeysDict(collections.KeyTransformingDict): 5 | """ 6 | An implementation of a KeyTransformingDict subclass that always converts 7 | the keys to strings. 8 | """ 9 | 10 | @staticmethod 11 | def transform_key(key): 12 | return str(key) 13 | 14 | 15 | def test_always_lower_keys_dict(): 16 | """ 17 | Tests AlwaysLowerKeysDict to ensure KeyTransformingDict works. 18 | """ 19 | d = AlwaysStringKeysDict() 20 | d['farther'] = 'closer' 21 | d['Lasting'] = 'fleeting' 22 | d[3] = 'three' 23 | d[{'a': 1}] = 'a is one' 24 | assert all(isinstance(key, str) for key in d) 25 | assert "{'a': 1}" in d 26 | assert 3 in d 27 | assert d[3] == d['3'] == 'three' 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.collections" 12 | authors = [ 13 | { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, 14 | ] 15 | description = "Collection objects similar to those in stdlib by jaraco" 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 | "jaraco.text", 27 | ] 28 | dynamic = ["version"] 29 | 30 | [project.urls] 31 | Source = "https://github.com/jaraco/jaraco.collections" 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | # jaraco/jaraco.collections#11 61 | nitpick_ignore += [ 62 | ('py:class', 'v, remove specified key and return the corresponding value.'), 63 | ('py:class', 'None. Update D from mapping/iterable E and F.'), 64 | ('py:class', 'D[k] if k in D, else d. d defaults to None.'), 65 | ] 66 | 67 | nitpick_ignore += [ 68 | ('py:class', 're.Pattern'), 69 | ] 70 | 71 | # jaraco/jaraco.collections#16 72 | nitpick_ignore += [ 73 | ('py:class', 'SupportsKeysAndGetItem'), 74 | ('py:class', '_RangeMapKT'), 75 | ('py:class', '_VT'), 76 | ('py:class', '_T'), 77 | ('py:class', 'jaraco.collections._RangeMapKT'), 78 | ('py:class', 'jaraco.collections._VT'), 79 | ('py:class', 'jaraco.collections._T'), 80 | ('py:obj', 'jaraco.collections._RangeMapKT'), 81 | ('py:obj', 'jaraco.collections._VT'), 82 | ] 83 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/jaraco.collections.svg 2 | :target: https://pypi.org/project/jaraco.collections 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/jaraco.collections.svg 5 | 6 | .. image:: https://github.com/jaraco/jaraco.collections/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/jaraco/jaraco.collections/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/jaracocollections/badge/?version=latest 15 | :target: https://jaracocollections.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.collections 21 | :target: https://tidelift.com/subscription/pkg/pypi-jaraco.collections?utm_source=pypi-jaraco.collections&utm_medium=readme 22 | 23 | Models and classes to supplement the stdlib 'collections' module. 24 | 25 | See the docs, linked above, for descriptions and usage examples. 26 | 27 | Highlights include: 28 | 29 | - RangeMap: A mapping that accepts a range of values for keys. 30 | - Projection: A subset over an existing mapping. 31 | - KeyTransformingDict: Generalized mapping with keys transformed by a function. 32 | - FoldedCaseKeyedDict: A dict whose string keys are case-insensitive. 33 | - BijectiveMap: A map where keys map to values and values back to their keys. 34 | - ItemsAsAttributes: A mapping mix-in exposing items as attributes. 35 | - IdentityOverrideMap: A map whose keys map by default to themselves unless overridden. 36 | - FrozenDict: A hashable, immutable map. 37 | - Enumeration: An object whose keys are enumerated. 38 | - Everything: A container that contains all things. 39 | - Least, Greatest: Objects that are always less than or greater than any other. 40 | - pop_all: Return all items from the mutable sequence and remove them from that sequence. 41 | - DictStack: A stack of dicts, great for sharing scopes. 42 | - WeightedLookup: A specialized RangeMap for selecting an item by weights. 43 | 44 | For Enterprise 45 | ============== 46 | 47 | Available as part of the Tidelift Subscription. 48 | 49 | 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. 50 | 51 | `Learn more `_. 52 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | v5.2.1 2 | ====== 3 | 4 | Bugfixes 5 | -------- 6 | 7 | - Fixed issue when the defaults included the key 'target'. 8 | 9 | 10 | v5.2.0 11 | ====== 12 | 13 | Features 14 | -------- 15 | 16 | - Added set_defaults function. 17 | 18 | 19 | v5.1.1 20 | ====== 21 | 22 | No significant changes. 23 | 24 | 25 | v5.1.0 26 | ====== 27 | 28 | Features 29 | -------- 30 | 31 | - Fully typed ``RangeMap`` and avoid complete iterations to find matches (#16) 32 | 33 | 34 | v5.0.1 35 | ====== 36 | 37 | Bugfixes 38 | -------- 39 | 40 | - Delinting and package refresh. 41 | 42 | 43 | v5.0.0 44 | ====== 45 | 46 | Features 47 | -------- 48 | 49 | - Moved collections into a package and declared as typed. 50 | 51 | 52 | Deprecations and Removals 53 | ------------------------- 54 | 55 | - Removed DictFilter. 56 | 57 | 58 | v4.3.0 59 | ====== 60 | 61 | Features 62 | -------- 63 | 64 | - Require Python 3.8 or later. 65 | 66 | 67 | v4.2.0 68 | ====== 69 | 70 | Added ``Mask``, the inverse of a ``Projection``. 71 | 72 | v4.1.0 73 | ====== 74 | 75 | ``Projection`` now accepts an iterable or callable or pattern 76 | for matching keys. 77 | 78 | ``Projection`` now retains order of keys from the underlying 79 | mapping. 80 | 81 | ``DictFilter`` is now deprecated in favor of ``Projection``. 82 | 83 | v4.0.0 84 | ====== 85 | 86 | ``DictFilter`` no longer accepts ``include_keys`` and requires 87 | ``include_pattern`` as a keyword argument. 88 | 89 | v3.11.0 90 | ======= 91 | 92 | In ``DictFilter``, deprecated ``include_keys`` parameter and usage 93 | without ``include_pattern``. Future versions will honor 94 | ``include_pattern`` as a required keyword argument. All other 95 | uses are deprecated. For uses that currently rely on ``include_keys``, 96 | use ``Projection`` instead/in addition. For example, instead of:: 97 | 98 | filtered = DictFilter(orig, include_keys=['a'], include_pattern='b+') 99 | 100 | Use:: 101 | 102 | filtered = DictFilter(Projection(['a'], orig), include_pattern='b+') 103 | 104 | v3.10.0 105 | ======= 106 | 107 | In ``Projection``, harmonize the implementation and optimize using 108 | ``set`` instead of ``tuple``. 109 | 110 | v3.9.0 111 | ====== 112 | 113 | ``DictFilter.__len__`` no longer relies on the iterable. Improves 114 | efficiency and fixes ``RecursionError`` on PyPy (#12). 115 | 116 | v3.8.0 117 | ====== 118 | 119 | Made ``DictStack`` mutable. 120 | 121 | v3.7.0 122 | ====== 123 | 124 | Added ``RangeMap.left``. 125 | 126 | v3.6.0 127 | ====== 128 | 129 | Revised ``DictFilter``: 130 | 131 | - Fixed issue where ``DictFilter.__contains__`` would raise a ``KeyError``. 132 | - Relies heavily now on ``collections.abc.Mapping`` base class. 133 | 134 | v3.5.2 135 | ====== 136 | 137 | Packaging refresh. 138 | 139 | Enrolled with Tidelift. 140 | 141 | v3.5.1 142 | ====== 143 | 144 | Fixed ``DictStack.__len__`` and addressed recursion error on 145 | PyPy in ``__getitem__``. 146 | 147 | v3.5.0 148 | ====== 149 | 150 | ``DictStack`` now supports the following Mapping behaviors: 151 | 152 | - ``.items()`` 153 | - casting to a dict 154 | - ``__contains__`` (i.e. "x in stack") 155 | 156 | Require Python 3.7 or later. 157 | 158 | v3.4.0 159 | ====== 160 | 161 | Add ``WeightedLookup``. 162 | 163 | v3.3.0 164 | ====== 165 | 166 | Add ``FreezableDefaultDict``. 167 | 168 | v3.2.0 169 | ====== 170 | 171 | Rely on PEP 420 for namespace package. 172 | 173 | v3.1.0 174 | ====== 175 | 176 | Refreshed packaging. Dropped dependency on six. 177 | 178 | v3.0.0 179 | ====== 180 | 181 | Require Python 3.6 or later. 182 | 183 | 2.1 184 | === 185 | 186 | Added ``pop_all`` function. 187 | 188 | 2.0 189 | === 190 | 191 | Switch to `pkgutil namespace technique 192 | `_ 193 | for the ``jaraco`` namespace. 194 | 195 | 1.6.0 196 | ===== 197 | 198 | Fix DeprecationWarnings when referencing abstract base 199 | classes from collections module. 200 | 201 | 1.5.3 202 | ===== 203 | 204 | Refresh package metadata. 205 | 206 | 1.5.2 207 | ===== 208 | 209 | Fixed KeyError in BijectiveMap when a new value matched 210 | an existing key (but not the reverse). Now a ValueError 211 | is raised as intended. 212 | 213 | 1.5.1 214 | ===== 215 | 216 | Refresh packaging. 217 | 218 | 1.5 219 | === 220 | 221 | Added a ``Projection`` class providing a much simpler 222 | interface than DictFilter. 223 | 224 | 1.4.1 225 | ===== 226 | 227 | #3: Fixed less-than-equal and greater-than-equal comparisons 228 | in ``Least`` and ``Greatest``. 229 | 230 | 1.4 231 | === 232 | 233 | Added ``Least`` and ``Greatest`` classes, instances of 234 | which always compare lesser or greater than all other 235 | objects. 236 | 237 | 1.3.2 238 | ===== 239 | 240 | Fixed failure of KeyTransformingDict to transform keys 241 | on calls to ``.get``. 242 | 243 | 1.3 244 | === 245 | 246 | Moved hosting to Github. 247 | 248 | 1.2.2 249 | ===== 250 | 251 | Restore Python 2.7 compatibility. 252 | 253 | 1.2 254 | === 255 | 256 | Add InstrumentedDict. 257 | 258 | 1.1 259 | === 260 | 261 | Conditionally require setup requirements. 262 | 263 | 1.0 264 | === 265 | 266 | Initial functionality taken from jaraco.util 10.8. 267 | -------------------------------------------------------------------------------- /jaraco/collections/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections.abc 4 | import copy 5 | import functools 6 | import itertools 7 | import operator 8 | import random 9 | import re 10 | from collections.abc import Container, Iterable, Mapping 11 | from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union, overload 12 | 13 | import jaraco.text 14 | 15 | if TYPE_CHECKING: 16 | from _operator import _SupportsComparison 17 | 18 | from _typeshed import SupportsKeysAndGetItem 19 | from typing_extensions import Self 20 | 21 | _RangeMapKT = TypeVar('_RangeMapKT', bound=_SupportsComparison) 22 | else: 23 | # _SupportsComparison doesn't exist at runtime, 24 | # but _RangeMapKT is used in RangeMap's superclass' type parameters 25 | _RangeMapKT = TypeVar('_RangeMapKT') 26 | 27 | _T = TypeVar('_T') 28 | _VT = TypeVar('_VT') 29 | 30 | _Matchable = Union[Callable, Container, Iterable, re.Pattern] 31 | 32 | 33 | def _dispatch(obj: _Matchable) -> Callable: 34 | # can't rely on singledispatch for Union[Container, Iterable] 35 | # due to ambiguity 36 | # (https://peps.python.org/pep-0443/#abstract-base-classes). 37 | if isinstance(obj, re.Pattern): 38 | return obj.fullmatch 39 | # mypy issue: https://github.com/python/mypy/issues/11071 40 | if not isinstance(obj, Callable): # type: ignore[arg-type] 41 | if not isinstance(obj, Container): 42 | obj = set(obj) # type: ignore[arg-type] 43 | obj = obj.__contains__ 44 | return obj # type: ignore[return-value] 45 | 46 | 47 | class Projection(collections.abc.Mapping): 48 | """ 49 | Project a set of keys over a mapping 50 | 51 | >>> sample = {'a': 1, 'b': 2, 'c': 3} 52 | >>> prj = Projection(['a', 'c', 'd'], sample) 53 | >>> dict(prj) 54 | {'a': 1, 'c': 3} 55 | 56 | Projection also accepts an iterable or callable or pattern. 57 | 58 | >>> iter_prj = Projection(iter('acd'), sample) 59 | >>> call_prj = Projection(lambda k: ord(k) in (97, 99, 100), sample) 60 | >>> pat_prj = Projection(re.compile(r'[acd]'), sample) 61 | >>> prj == iter_prj == call_prj == pat_prj 62 | True 63 | 64 | Keys should only appear if they were specified and exist in the space. 65 | Order is retained. 66 | 67 | >>> list(prj) 68 | ['a', 'c'] 69 | 70 | Attempting to access a key not in the projection 71 | results in a KeyError. 72 | 73 | >>> prj['b'] 74 | Traceback (most recent call last): 75 | ... 76 | KeyError: 'b' 77 | 78 | Use the projection to update another dict. 79 | 80 | >>> target = {'a': 2, 'b': 2} 81 | >>> target.update(prj) 82 | >>> target 83 | {'a': 1, 'b': 2, 'c': 3} 84 | 85 | Projection keeps a reference to the original dict, so 86 | modifying the original dict may modify the Projection. 87 | 88 | >>> del sample['a'] 89 | >>> dict(prj) 90 | {'c': 3} 91 | """ 92 | 93 | def __init__(self, keys: _Matchable, space: Mapping): 94 | self._match = _dispatch(keys) 95 | self._space = space 96 | 97 | def __getitem__(self, key): 98 | if not self._match(key): 99 | raise KeyError(key) 100 | return self._space[key] 101 | 102 | def _keys_resolved(self): 103 | return filter(self._match, self._space) 104 | 105 | def __iter__(self): 106 | return self._keys_resolved() 107 | 108 | def __len__(self): 109 | return len(tuple(self._keys_resolved())) 110 | 111 | 112 | class Mask(Projection): 113 | """ 114 | The inverse of a :class:`Projection`, masking out keys. 115 | 116 | >>> sample = {'a': 1, 'b': 2, 'c': 3} 117 | >>> msk = Mask(['a', 'c', 'd'], sample) 118 | >>> dict(msk) 119 | {'b': 2} 120 | """ 121 | 122 | def __init__(self, *args, **kwargs): 123 | super().__init__(*args, **kwargs) 124 | # self._match = compose(operator.not_, self._match) 125 | self._match = lambda key, orig=self._match: not orig(key) 126 | 127 | 128 | def dict_map(function, dictionary): 129 | """ 130 | Return a new dict with function applied to values of dictionary. 131 | 132 | >>> dict_map(lambda x: x+1, dict(a=1, b=2)) 133 | {'a': 2, 'b': 3} 134 | """ 135 | return dict((key, function(value)) for key, value in dictionary.items()) 136 | 137 | 138 | class RangeMap(dict[_RangeMapKT, _VT]): 139 | """ 140 | A dictionary-like object that uses the keys as bounds for a range. 141 | Inclusion of the value for that range is determined by the 142 | key_match_comparator, which defaults to less-than-or-equal. 143 | A value is returned for a key if it is the first key that matches in 144 | the sorted list of keys. 145 | 146 | One may supply keyword parameters to be passed to the sort function used 147 | to sort keys (i.e. key, reverse) as sort_params. 148 | 149 | Create a map that maps 1-3 -> 'a', 4-6 -> 'b' 150 | 151 | >>> r = RangeMap({3: 'a', 6: 'b'}) # boy, that was easy 152 | >>> r[1], r[2], r[3], r[4], r[5], r[6] 153 | ('a', 'a', 'a', 'b', 'b', 'b') 154 | 155 | Even float values should work so long as the comparison operator 156 | supports it. 157 | 158 | >>> r[4.5] 159 | 'b' 160 | 161 | Notice that the way rangemap is defined, it must be open-ended 162 | on one side. 163 | 164 | >>> r[0] 165 | 'a' 166 | >>> r[-1] 167 | 'a' 168 | 169 | One can close the open-end of the RangeMap by using undefined_value 170 | 171 | >>> r = RangeMap({0: RangeMap.undefined_value, 3: 'a', 6: 'b'}) 172 | >>> r[0] 173 | Traceback (most recent call last): 174 | ... 175 | KeyError: 0 176 | 177 | One can get the first or last elements in the range by using RangeMap.Item 178 | 179 | >>> last_item = RangeMap.Item(-1) 180 | >>> r[last_item] 181 | 'b' 182 | 183 | .last_item is a shortcut for Item(-1) 184 | 185 | >>> r[RangeMap.last_item] 186 | 'b' 187 | 188 | Sometimes it's useful to find the bounds for a RangeMap 189 | 190 | >>> r.bounds() 191 | (0, 6) 192 | 193 | RangeMap supports .get(key, default) 194 | 195 | >>> r.get(0, 'not found') 196 | 'not found' 197 | 198 | >>> r.get(7, 'not found') 199 | 'not found' 200 | 201 | One often wishes to define the ranges by their left-most values, 202 | which requires use of sort params and a key_match_comparator. 203 | 204 | >>> r = RangeMap({1: 'a', 4: 'b'}, 205 | ... sort_params=dict(reverse=True), 206 | ... key_match_comparator=operator.ge) 207 | >>> r[1], r[2], r[3], r[4], r[5], r[6] 208 | ('a', 'a', 'a', 'b', 'b', 'b') 209 | 210 | That wasn't nearly as easy as before, so an alternate constructor 211 | is provided: 212 | 213 | >>> r = RangeMap.left({1: 'a', 4: 'b', 7: RangeMap.undefined_value}) 214 | >>> r[1], r[2], r[3], r[4], r[5], r[6] 215 | ('a', 'a', 'a', 'b', 'b', 'b') 216 | 217 | """ 218 | 219 | def __init__( 220 | self, 221 | source: ( 222 | SupportsKeysAndGetItem[_RangeMapKT, _VT] | Iterable[tuple[_RangeMapKT, _VT]] 223 | ), 224 | sort_params: Mapping[str, Any] = {}, 225 | key_match_comparator: Callable[[_RangeMapKT, _RangeMapKT], bool] = operator.le, 226 | ): 227 | dict.__init__(self, source) 228 | self.sort_params = sort_params 229 | self.match = key_match_comparator 230 | 231 | @classmethod 232 | def left( 233 | cls, 234 | source: ( 235 | SupportsKeysAndGetItem[_RangeMapKT, _VT] | Iterable[tuple[_RangeMapKT, _VT]] 236 | ), 237 | ) -> Self: 238 | return cls( 239 | source, sort_params=dict(reverse=True), key_match_comparator=operator.ge 240 | ) 241 | 242 | def __getitem__(self, item: _RangeMapKT) -> _VT: 243 | sorted_keys = sorted(self.keys(), **self.sort_params) 244 | if isinstance(item, RangeMap.Item): 245 | result = self.__getitem__(sorted_keys[item]) 246 | else: 247 | key = self._find_first_match_(sorted_keys, item) 248 | result = dict.__getitem__(self, key) 249 | if result is RangeMap.undefined_value: 250 | raise KeyError(key) 251 | return result 252 | 253 | @overload # type: ignore[override] # Signature simplified over dict and Mapping 254 | def get(self, key: _RangeMapKT, default: _T) -> _VT | _T: ... 255 | @overload 256 | def get(self, key: _RangeMapKT, default: None = None) -> _VT | None: ... 257 | def get(self, key: _RangeMapKT, default: _T | None = None) -> _VT | _T | None: 258 | """ 259 | Return the value for key if key is in the dictionary, else default. 260 | If default is not given, it defaults to None, so that this method 261 | never raises a KeyError. 262 | """ 263 | try: 264 | return self[key] 265 | except KeyError: 266 | return default 267 | 268 | def _find_first_match_( 269 | self, keys: Iterable[_RangeMapKT], item: _RangeMapKT 270 | ) -> _RangeMapKT: 271 | is_match = functools.partial(self.match, item) 272 | matches = filter(is_match, keys) 273 | try: 274 | return next(matches) 275 | except StopIteration: 276 | raise KeyError(item) from None 277 | 278 | def bounds(self) -> tuple[_RangeMapKT, _RangeMapKT]: 279 | sorted_keys = sorted(self.keys(), **self.sort_params) 280 | return (sorted_keys[RangeMap.first_item], sorted_keys[RangeMap.last_item]) 281 | 282 | # some special values for the RangeMap 283 | undefined_value = type('RangeValueUndefined', (), {})() 284 | 285 | class Item(int): 286 | """RangeMap Item""" 287 | 288 | first_item = Item(0) 289 | last_item = Item(-1) 290 | 291 | 292 | def __identity(x): 293 | return x 294 | 295 | 296 | def sorted_items(d, key=__identity, reverse=False): 297 | """ 298 | Return the items of the dictionary sorted by the keys. 299 | 300 | >>> sample = dict(foo=20, bar=42, baz=10) 301 | >>> tuple(sorted_items(sample)) 302 | (('bar', 42), ('baz', 10), ('foo', 20)) 303 | 304 | >>> reverse_string = lambda s: ''.join(reversed(s)) 305 | >>> tuple(sorted_items(sample, key=reverse_string)) 306 | (('foo', 20), ('bar', 42), ('baz', 10)) 307 | 308 | >>> tuple(sorted_items(sample, reverse=True)) 309 | (('foo', 20), ('baz', 10), ('bar', 42)) 310 | """ 311 | 312 | # wrap the key func so it operates on the first element of each item 313 | def pairkey_key(item): 314 | return key(item[0]) 315 | 316 | return sorted(d.items(), key=pairkey_key, reverse=reverse) 317 | 318 | 319 | class KeyTransformingDict(dict): 320 | """ 321 | A dict subclass that transforms the keys before they're used. 322 | Subclasses may override the default transform_key to customize behavior. 323 | """ 324 | 325 | @staticmethod 326 | def transform_key(key): # pragma: nocover 327 | return key 328 | 329 | def __init__(self, *args, **kargs): 330 | super().__init__() 331 | # build a dictionary using the default constructs 332 | d = dict(*args, **kargs) 333 | # build this dictionary using transformed keys. 334 | for item in d.items(): 335 | self.__setitem__(*item) 336 | 337 | def __setitem__(self, key, val): 338 | key = self.transform_key(key) 339 | super().__setitem__(key, val) 340 | 341 | def __getitem__(self, key): 342 | key = self.transform_key(key) 343 | return super().__getitem__(key) 344 | 345 | def __contains__(self, key): 346 | key = self.transform_key(key) 347 | return super().__contains__(key) 348 | 349 | def __delitem__(self, key): 350 | key = self.transform_key(key) 351 | return super().__delitem__(key) 352 | 353 | def get(self, key, *args, **kwargs): 354 | key = self.transform_key(key) 355 | return super().get(key, *args, **kwargs) 356 | 357 | def setdefault(self, key, *args, **kwargs): 358 | key = self.transform_key(key) 359 | return super().setdefault(key, *args, **kwargs) 360 | 361 | def pop(self, key, *args, **kwargs): 362 | key = self.transform_key(key) 363 | return super().pop(key, *args, **kwargs) 364 | 365 | def matching_key_for(self, key): 366 | """ 367 | Given a key, return the actual key stored in self that matches. 368 | Raise KeyError if the key isn't found. 369 | """ 370 | try: 371 | return next(e_key for e_key in self.keys() if e_key == key) 372 | except StopIteration as err: 373 | raise KeyError(key) from err 374 | 375 | 376 | class FoldedCaseKeyedDict(KeyTransformingDict): 377 | """ 378 | A case-insensitive dictionary (keys are compared as insensitive 379 | if they are strings). 380 | 381 | >>> d = FoldedCaseKeyedDict() 382 | >>> d['heLlo'] = 'world' 383 | >>> list(d.keys()) == ['heLlo'] 384 | True 385 | >>> list(d.values()) == ['world'] 386 | True 387 | >>> d['hello'] == 'world' 388 | True 389 | >>> 'hello' in d 390 | True 391 | >>> 'HELLO' in d 392 | True 393 | >>> print(repr(FoldedCaseKeyedDict({'heLlo': 'world'}))) 394 | {'heLlo': 'world'} 395 | >>> d = FoldedCaseKeyedDict({'heLlo': 'world'}) 396 | >>> print(d['hello']) 397 | world 398 | >>> print(d['Hello']) 399 | world 400 | >>> list(d.keys()) 401 | ['heLlo'] 402 | >>> d = FoldedCaseKeyedDict({'heLlo': 'world', 'Hello': 'world'}) 403 | >>> list(d.values()) 404 | ['world'] 405 | >>> key, = d.keys() 406 | >>> key in ['heLlo', 'Hello'] 407 | True 408 | >>> del d['HELLO'] 409 | >>> d 410 | {} 411 | 412 | get should work 413 | 414 | >>> d['Sumthin'] = 'else' 415 | >>> d.get('SUMTHIN') 416 | 'else' 417 | >>> d.get('OTHER', 'thing') 418 | 'thing' 419 | >>> del d['sumthin'] 420 | 421 | setdefault should also work 422 | 423 | >>> d['This'] = 'that' 424 | >>> print(d.setdefault('this', 'other')) 425 | that 426 | >>> len(d) 427 | 1 428 | >>> print(d['this']) 429 | that 430 | >>> print(d.setdefault('That', 'other')) 431 | other 432 | >>> print(d['THAT']) 433 | other 434 | 435 | Make it pop! 436 | 437 | >>> print(d.pop('THAT')) 438 | other 439 | 440 | To retrieve the key in its originally-supplied form, use matching_key_for 441 | 442 | >>> print(d.matching_key_for('this')) 443 | This 444 | 445 | >>> d.matching_key_for('missing') 446 | Traceback (most recent call last): 447 | ... 448 | KeyError: 'missing' 449 | """ 450 | 451 | @staticmethod 452 | def transform_key(key): 453 | return jaraco.text.FoldedCase(key) 454 | 455 | 456 | class DictAdapter: 457 | """ 458 | Provide a getitem interface for attributes of an object. 459 | 460 | Let's say you want to get at the string.lowercase property in a formatted 461 | string. It's easy with DictAdapter. 462 | 463 | >>> import string 464 | >>> print("lowercase is %(ascii_lowercase)s" % DictAdapter(string)) 465 | lowercase is abcdefghijklmnopqrstuvwxyz 466 | """ 467 | 468 | def __init__(self, wrapped_ob): 469 | self.object = wrapped_ob 470 | 471 | def __getitem__(self, name): 472 | return getattr(self.object, name) 473 | 474 | 475 | class ItemsAsAttributes: 476 | """ 477 | Mix-in class to enable a mapping object to provide items as 478 | attributes. 479 | 480 | >>> C = type('C', (dict, ItemsAsAttributes), dict()) 481 | >>> i = C() 482 | >>> i['foo'] = 'bar' 483 | >>> i.foo 484 | 'bar' 485 | 486 | Natural attribute access takes precedence 487 | 488 | >>> i.foo = 'henry' 489 | >>> i.foo 490 | 'henry' 491 | 492 | But as you might expect, the mapping functionality is preserved. 493 | 494 | >>> i['foo'] 495 | 'bar' 496 | 497 | A normal attribute error should be raised if an attribute is 498 | requested that doesn't exist. 499 | 500 | >>> i.missing 501 | Traceback (most recent call last): 502 | ... 503 | AttributeError: 'C' object has no attribute 'missing' 504 | 505 | It also works on dicts that customize __getitem__ 506 | 507 | >>> missing_func = lambda self, key: 'missing item' 508 | >>> C = type( 509 | ... 'C', 510 | ... (dict, ItemsAsAttributes), 511 | ... dict(__missing__ = missing_func), 512 | ... ) 513 | >>> i = C() 514 | >>> i.missing 515 | 'missing item' 516 | >>> i.foo 517 | 'missing item' 518 | """ 519 | 520 | def __getattr__(self, key): 521 | try: 522 | return getattr(super(), key) 523 | except AttributeError as e: 524 | # attempt to get the value from the mapping (return self[key]) 525 | # but be careful not to lose the original exception context. 526 | noval = object() 527 | 528 | def _safe_getitem(cont, key, missing_result): 529 | try: 530 | return cont[key] 531 | except KeyError: 532 | return missing_result 533 | 534 | result = _safe_getitem(self, key, noval) 535 | if result is not noval: 536 | return result 537 | # raise the original exception, but use the original class 538 | # name, not 'super'. 539 | (message,) = e.args 540 | message = message.replace('super', self.__class__.__name__, 1) 541 | e.args = (message,) 542 | raise 543 | 544 | 545 | def invert_map(map): 546 | """ 547 | Given a dictionary, return another dictionary with keys and values 548 | switched. If any of the values resolve to the same key, raises 549 | a ValueError. 550 | 551 | >>> numbers = dict(a=1, b=2, c=3) 552 | >>> letters = invert_map(numbers) 553 | >>> letters[1] 554 | 'a' 555 | >>> numbers['d'] = 3 556 | >>> invert_map(numbers) 557 | Traceback (most recent call last): 558 | ... 559 | ValueError: Key conflict in inverted mapping 560 | """ 561 | res = dict((v, k) for k, v in map.items()) 562 | if not len(res) == len(map): 563 | raise ValueError('Key conflict in inverted mapping') 564 | return res 565 | 566 | 567 | class IdentityOverrideMap(dict): 568 | """ 569 | A dictionary that by default maps each key to itself, but otherwise 570 | acts like a normal dictionary. 571 | 572 | >>> d = IdentityOverrideMap() 573 | >>> d[42] 574 | 42 575 | >>> d['speed'] = 'speedo' 576 | >>> print(d['speed']) 577 | speedo 578 | """ 579 | 580 | def __missing__(self, key): 581 | return key 582 | 583 | 584 | class DictStack(list, collections.abc.MutableMapping): 585 | """ 586 | A stack of dictionaries that behaves as a view on those dictionaries, 587 | giving preference to the last. 588 | 589 | >>> stack = DictStack([dict(a=1, c=2), dict(b=2, a=2)]) 590 | >>> stack['a'] 591 | 2 592 | >>> stack['b'] 593 | 2 594 | >>> stack['c'] 595 | 2 596 | >>> len(stack) 597 | 3 598 | >>> stack.push(dict(a=3)) 599 | >>> stack['a'] 600 | 3 601 | >>> stack['a'] = 4 602 | >>> set(stack.keys()) == set(['a', 'b', 'c']) 603 | True 604 | >>> set(stack.items()) == set([('a', 4), ('b', 2), ('c', 2)]) 605 | True 606 | >>> dict(**stack) == dict(stack) == dict(a=4, c=2, b=2) 607 | True 608 | >>> d = stack.pop() 609 | >>> stack['a'] 610 | 2 611 | >>> d = stack.pop() 612 | >>> stack['a'] 613 | 1 614 | >>> stack.get('b', None) 615 | >>> 'c' in stack 616 | True 617 | >>> del stack['c'] 618 | >>> dict(stack) 619 | {'a': 1} 620 | """ 621 | 622 | def __iter__(self): 623 | dicts = list.__iter__(self) 624 | return iter(set(itertools.chain.from_iterable(c.keys() for c in dicts))) 625 | 626 | def __getitem__(self, key): 627 | for scope in reversed(tuple(list.__iter__(self))): 628 | if key in scope: 629 | return scope[key] 630 | raise KeyError(key) 631 | 632 | push = list.append 633 | 634 | def __contains__(self, other): 635 | return collections.abc.Mapping.__contains__(self, other) 636 | 637 | def __len__(self): 638 | return len(list(iter(self))) 639 | 640 | def __setitem__(self, key, item): 641 | last = list.__getitem__(self, -1) 642 | return last.__setitem__(key, item) 643 | 644 | def __delitem__(self, key): 645 | last = list.__getitem__(self, -1) 646 | return last.__delitem__(key) 647 | 648 | # workaround for mypy confusion 649 | def pop(self, *args, **kwargs): 650 | return list.pop(self, *args, **kwargs) 651 | 652 | 653 | class BijectiveMap(dict): 654 | """ 655 | A Bijective Map (two-way mapping). 656 | 657 | Implemented as a simple dictionary of 2x the size, mapping values back 658 | to keys. 659 | 660 | Note, this implementation may be incomplete. If there's not a test for 661 | your use case below, it's likely to fail, so please test and send pull 662 | requests or patches for additional functionality needed. 663 | 664 | 665 | >>> m = BijectiveMap() 666 | >>> m['a'] = 'b' 667 | >>> m == {'a': 'b', 'b': 'a'} 668 | True 669 | >>> print(m['b']) 670 | a 671 | 672 | >>> m['c'] = 'd' 673 | >>> len(m) 674 | 2 675 | 676 | Some weird things happen if you map an item to itself or overwrite a 677 | single key of a pair, so it's disallowed. 678 | 679 | >>> m['e'] = 'e' 680 | Traceback (most recent call last): 681 | ValueError: Key cannot map to itself 682 | 683 | >>> m['d'] = 'e' 684 | Traceback (most recent call last): 685 | ValueError: Key/Value pairs may not overlap 686 | 687 | >>> m['e'] = 'd' 688 | Traceback (most recent call last): 689 | ValueError: Key/Value pairs may not overlap 690 | 691 | >>> print(m.pop('d')) 692 | c 693 | 694 | >>> 'c' in m 695 | False 696 | 697 | >>> m = BijectiveMap(dict(a='b')) 698 | >>> len(m) 699 | 1 700 | >>> print(m['b']) 701 | a 702 | 703 | >>> m = BijectiveMap() 704 | >>> m.update(a='b') 705 | >>> m['b'] 706 | 'a' 707 | 708 | >>> del m['b'] 709 | >>> len(m) 710 | 0 711 | >>> 'a' in m 712 | False 713 | """ 714 | 715 | def __init__(self, *args, **kwargs): 716 | super().__init__() 717 | self.update(*args, **kwargs) 718 | 719 | def __setitem__(self, item, value): 720 | if item == value: 721 | raise ValueError("Key cannot map to itself") 722 | overlap = ( 723 | item in self 724 | and self[item] != value 725 | or value in self 726 | and self[value] != item 727 | ) 728 | if overlap: 729 | raise ValueError("Key/Value pairs may not overlap") 730 | super().__setitem__(item, value) 731 | super().__setitem__(value, item) 732 | 733 | def __delitem__(self, item): 734 | self.pop(item) 735 | 736 | def __len__(self): 737 | return super().__len__() // 2 738 | 739 | def pop(self, key, *args, **kwargs): 740 | mirror = self[key] 741 | super().__delitem__(mirror) 742 | return super().pop(key, *args, **kwargs) 743 | 744 | def update(self, *args, **kwargs): 745 | # build a dictionary using the default constructs 746 | d = dict(*args, **kwargs) 747 | # build this dictionary using transformed keys. 748 | for item in d.items(): 749 | self.__setitem__(*item) 750 | 751 | 752 | class FrozenDict(collections.abc.Mapping, collections.abc.Hashable): 753 | """ 754 | An immutable mapping. 755 | 756 | >>> a = FrozenDict(a=1, b=2) 757 | >>> b = FrozenDict(a=1, b=2) 758 | >>> a == b 759 | True 760 | 761 | >>> a == dict(a=1, b=2) 762 | True 763 | >>> dict(a=1, b=2) == a 764 | True 765 | >>> 'a' in a 766 | True 767 | >>> type(hash(a)) is type(0) 768 | True 769 | >>> set(iter(a)) == {'a', 'b'} 770 | True 771 | >>> len(a) 772 | 2 773 | >>> a['a'] == a.get('a') == 1 774 | True 775 | 776 | >>> a['c'] = 3 777 | Traceback (most recent call last): 778 | ... 779 | TypeError: 'FrozenDict' object does not support item assignment 780 | 781 | >>> a.update(y=3) 782 | Traceback (most recent call last): 783 | ... 784 | AttributeError: 'FrozenDict' object has no attribute 'update' 785 | 786 | Copies should compare equal 787 | 788 | >>> copy.copy(a) == a 789 | True 790 | 791 | Copies should be the same type 792 | 793 | >>> isinstance(copy.copy(a), FrozenDict) 794 | True 795 | 796 | FrozenDict supplies .copy(), even though 797 | collections.abc.Mapping doesn't demand it. 798 | 799 | >>> a.copy() == a 800 | True 801 | >>> a.copy() is not a 802 | True 803 | """ 804 | 805 | __slots__ = ['__data'] 806 | 807 | def __new__(cls, *args, **kwargs): 808 | self = super().__new__(cls) 809 | self.__data = dict(*args, **kwargs) 810 | return self 811 | 812 | # Container 813 | def __contains__(self, key): 814 | return key in self.__data 815 | 816 | # Hashable 817 | def __hash__(self): 818 | return hash(tuple(sorted(self.__data.items()))) 819 | 820 | # Mapping 821 | def __iter__(self): 822 | return iter(self.__data) 823 | 824 | def __len__(self): 825 | return len(self.__data) 826 | 827 | def __getitem__(self, key): 828 | return self.__data[key] 829 | 830 | # override get for efficiency provided by dict 831 | def get(self, *args, **kwargs): 832 | return self.__data.get(*args, **kwargs) 833 | 834 | # override eq to recognize underlying implementation 835 | def __eq__(self, other): 836 | if isinstance(other, FrozenDict): 837 | other = other.__data 838 | return self.__data.__eq__(other) 839 | 840 | def copy(self): 841 | "Return a shallow copy of self" 842 | return copy.copy(self) 843 | 844 | 845 | class Enumeration(ItemsAsAttributes, BijectiveMap): 846 | """ 847 | A convenient way to provide enumerated values 848 | 849 | >>> e = Enumeration('a b c') 850 | >>> e['a'] 851 | 0 852 | 853 | >>> e.a 854 | 0 855 | 856 | >>> e[1] 857 | 'b' 858 | 859 | >>> set(e.names) == set('abc') 860 | True 861 | 862 | >>> set(e.codes) == set(range(3)) 863 | True 864 | 865 | >>> e.get('d') is None 866 | True 867 | 868 | Codes need not start with 0 869 | 870 | >>> e = Enumeration('a b c', range(1, 4)) 871 | >>> e['a'] 872 | 1 873 | 874 | >>> e[3] 875 | 'c' 876 | """ 877 | 878 | def __init__(self, names, codes=None): 879 | if isinstance(names, str): 880 | names = names.split() 881 | if codes is None: 882 | codes = itertools.count() 883 | super().__init__(zip(names, codes)) 884 | 885 | @property 886 | def names(self): 887 | return (key for key in self if isinstance(key, str)) 888 | 889 | @property 890 | def codes(self): 891 | return (self[name] for name in self.names) 892 | 893 | 894 | class Everything: 895 | """ 896 | A collection "containing" every possible thing. 897 | 898 | >>> 'foo' in Everything() 899 | True 900 | 901 | >>> import random 902 | >>> random.randint(1, 999) in Everything() 903 | True 904 | 905 | >>> random.choice([None, 'foo', 42, ('a', 'b', 'c')]) in Everything() 906 | True 907 | """ 908 | 909 | def __contains__(self, other): 910 | return True 911 | 912 | 913 | class InstrumentedDict(collections.UserDict): 914 | """ 915 | Instrument an existing dictionary with additional 916 | functionality, but always reference and mutate 917 | the original dictionary. 918 | 919 | >>> orig = {'a': 1, 'b': 2} 920 | >>> inst = InstrumentedDict(orig) 921 | >>> inst['a'] 922 | 1 923 | >>> inst['c'] = 3 924 | >>> orig['c'] 925 | 3 926 | >>> inst.keys() == orig.keys() 927 | True 928 | """ 929 | 930 | def __init__(self, data): 931 | super().__init__() 932 | self.data = data 933 | 934 | 935 | class Least: 936 | """ 937 | A value that is always lesser than any other 938 | 939 | >>> least = Least() 940 | >>> 3 < least 941 | False 942 | >>> 3 > least 943 | True 944 | >>> least < 3 945 | True 946 | >>> least <= 3 947 | True 948 | >>> least > 3 949 | False 950 | >>> 'x' > least 951 | True 952 | >>> None > least 953 | True 954 | """ 955 | 956 | def __le__(self, other): 957 | return True 958 | 959 | __lt__ = __le__ 960 | 961 | def __ge__(self, other): 962 | return False 963 | 964 | __gt__ = __ge__ 965 | 966 | 967 | class Greatest: 968 | """ 969 | A value that is always greater than any other 970 | 971 | >>> greatest = Greatest() 972 | >>> 3 < greatest 973 | True 974 | >>> 3 > greatest 975 | False 976 | >>> greatest < 3 977 | False 978 | >>> greatest > 3 979 | True 980 | >>> greatest >= 3 981 | True 982 | >>> 'x' > greatest 983 | False 984 | >>> None > greatest 985 | False 986 | """ 987 | 988 | def __ge__(self, other): 989 | return True 990 | 991 | __gt__ = __ge__ 992 | 993 | def __le__(self, other): 994 | return False 995 | 996 | __lt__ = __le__ 997 | 998 | 999 | def pop_all(items): 1000 | """ 1001 | Clear items in place and return a copy of items. 1002 | 1003 | >>> items = [1, 2, 3] 1004 | >>> popped = pop_all(items) 1005 | >>> popped is items 1006 | False 1007 | >>> popped 1008 | [1, 2, 3] 1009 | >>> items 1010 | [] 1011 | """ 1012 | result, items[:] = items[:], [] 1013 | return result 1014 | 1015 | 1016 | class FreezableDefaultDict(collections.defaultdict): 1017 | """ 1018 | Often it is desirable to prevent the mutation of 1019 | a default dict after its initial construction, such 1020 | as to prevent mutation during iteration. 1021 | 1022 | >>> dd = FreezableDefaultDict(list) 1023 | >>> dd[0].append('1') 1024 | >>> dd.freeze() 1025 | >>> dd[1] 1026 | [] 1027 | >>> len(dd) 1028 | 1 1029 | """ 1030 | 1031 | def __missing__(self, key): 1032 | return getattr(self, '_frozen', super().__missing__)(key) 1033 | 1034 | def freeze(self): 1035 | self._frozen = lambda key: self.default_factory() 1036 | 1037 | 1038 | class Accumulator: 1039 | def __init__(self, initial=0): 1040 | self.val = initial 1041 | 1042 | def __call__(self, val): 1043 | self.val += val 1044 | return self.val 1045 | 1046 | 1047 | class WeightedLookup(RangeMap): 1048 | """ 1049 | Given parameters suitable for a dict representing keys 1050 | and a weighted proportion, return a RangeMap representing 1051 | spans of values proportial to the weights: 1052 | 1053 | >>> even = WeightedLookup(a=1, b=1) 1054 | 1055 | [0, 1) -> a 1056 | [1, 2) -> b 1057 | 1058 | >>> lk = WeightedLookup(a=1, b=2) 1059 | 1060 | [0, 1) -> a 1061 | [1, 3) -> b 1062 | 1063 | >>> lk[.5] 1064 | 'a' 1065 | >>> lk[1.5] 1066 | 'b' 1067 | 1068 | Adds ``.random()`` to select a random weighted value: 1069 | 1070 | >>> lk.random() in ['a', 'b'] 1071 | True 1072 | 1073 | >>> choices = [lk.random() for x in range(1000)] 1074 | 1075 | Statistically speaking, choices should be .5 a:b 1076 | >>> ratio = choices.count('a') / choices.count('b') 1077 | >>> .4 < ratio < .6 1078 | True 1079 | """ 1080 | 1081 | def __init__(self, *args, **kwargs): 1082 | raw = dict(*args, **kwargs) 1083 | 1084 | # allocate keys by weight 1085 | indexes = map(Accumulator(), raw.values()) 1086 | super().__init__(zip(indexes, raw.keys()), key_match_comparator=operator.lt) 1087 | 1088 | def random(self): 1089 | lower, upper = self.bounds() 1090 | selector = random.random() * upper 1091 | return self[selector] 1092 | 1093 | 1094 | def set_defaults(__anon_self: dict[str, object], /, **defaults) -> None: 1095 | """ 1096 | Sets values on target in source not already in target. 1097 | 1098 | Like :meth:`dict.setdefault`, but applies to all keys. 1099 | 1100 | >>> target = dict(a=1, c=3) 1101 | >>> set_defaults(target, b=2, c=4) 1102 | >>> target 1103 | {'a': 1, 'c': 3, 'b': 2} 1104 | 1105 | The first parameter is bound to a name that's unlikely to 1106 | collide with the keys in defaults. 1107 | 1108 | >>> set_defaults(target, target=999) 1109 | """ 1110 | __anon_self.update(Mask(__anon_self.__contains__, defaults)) 1111 | --------------------------------------------------------------------------------