pax_global_header00006660000000000000000000000064145265245730014527gustar00rootroot0000000000000052 comment=fbc02ea61d8c65588e9c74724611715920711728 picobox-4.0.0/000077500000000000000000000000001452652457300131735ustar00rootroot00000000000000picobox-4.0.0/.github/000077500000000000000000000000001452652457300145335ustar00rootroot00000000000000picobox-4.0.0/.github/workflows/000077500000000000000000000000001452652457300165705ustar00rootroot00000000000000picobox-4.0.0/.github/workflows/cd.yml000066400000000000000000000010701452652457300176770ustar00rootroot00000000000000name: cd on: push: tags: - "[1-9]+.[0-9]+.[0-9]+" jobs: pypi: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v4 with: python-version: "3.12" - name: Prepare artifacts run: | pipx run -- hatch build - name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | pipx run -- twine upload dist/* picobox-4.0.0/.github/workflows/ci.yml000066400000000000000000000022371452652457300177120ustar00rootroot00000000000000name: ci on: push: branches: [master] pull_request: branches: [master] jobs: lint: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v4 with: python-version: "3.12" - name: Run lints run: pipx run -- hatch run lint:run test: strategy: matrix: os: [ubuntu-latest, macos-latest] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.8"] runs-on: ${{ matrix.os }} steps: - name: Checkout repository uses: actions/checkout@v3 - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Run pytest run: pipx run -- hatch run test:run docs: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v4 with: python-version: "3.12" - name: Run sphinx run: pipx run -- hatch run docs:run picobox-4.0.0/.gitignore000066400000000000000000000012131452652457300151600ustar00rootroot00000000000000# Backup files *.~ # Byte-compiles / optimizied __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging bin/ build/ develop-eggs/ dist/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg MANIFEST # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports .tox/ .coverage .coverage.* .cache .pytest_cache nosetests.xml coverage.xml # Translations *.mo # Sphinx documentation docs/_build/ # Ignore sublime's project (for fans) .ropeproject/ *.sublime-project *.sublime-workspace # Ignore virtualenvs (who places it near) .venv/ # Various shit from the OS itself .DS_Store picobox-4.0.0/.readthedocs.yaml000066400000000000000000000003341452652457300164220ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3" python: install: - requirements: docs/requirements.txt - method: pip path: . sphinx: configuration: docs/conf.py fail_on_warning: true picobox-4.0.0/LICENSE000066400000000000000000000020431452652457300141770ustar00rootroot00000000000000Copyright (c) 2017 Ihor Kalnytskyi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. picobox-4.0.0/README.rst000066400000000000000000000037211452652457300146650ustar00rootroot00000000000000Picobox ======= .. image:: https://img.shields.io/pypi/l/picobox :target: https://pypi.python.org/pypi/picobox :alt: PyPI - License .. image:: https://img.shields.io/pypi/v/picobox.svg :target: https://pypi.python.org/pypi/picobox :alt: PyPI - Version .. image:: https://img.shields.io/pypi/pyversions/picobox :target: https://pypi.python.org/pypi/picobox :alt: PyPI - Python Versions .. image:: https://img.shields.io/pypi/dm/picobox :target: https://pypi.python.org/pypi/picobox :alt: PyPI - Downloads Picobox is opinionated dependency injection framework designed to be clean, pragmatic and with Python in mind. No complex graphs, no implicit injections, no type bindings – just picoboxes, and explicit demands! Why? ---- Because we usually want to decouple our code and Python lack of clean and pragmatic solutions (even third parties). Features -------- * Support both values and factories. * Support scopes (e.g. singleton, threadlocal, contextvars). * Push boxes on stack, and use the top one to access values. * Thread-safe. * Lightweight (~500 LOC including scopes). * Zero dependencies. * Pure Python. * Annotated with types. Quickstart ---------- First .. code:: bash $ [sudo] python -m pip install picobox and then .. code:: python import picobox import requests @picobox.pass_("conf") @picobox.pass_("requests", as_="session") def get_resource(uri, session, conf): return session.get(conf["base_uri"] + uri) box = picobox.Box() box.put("conf", {"base_uri": "http://example.com"}) box.put("requests", factory=requests.Session, scope=picobox.threadlocal) with picobox.push(box): get_resource("/resource", requests.Session(), {}) get_resource("/resource", requests.Session()) get_resource("/resource") Links ----- * Documentation: https://picobox.readthedocs.io * Source: https://github.com/ikalnytskyi/picobox * Bugs: https://github.com/ikalnytskyi/picobox/issues picobox-4.0.0/docs/000077500000000000000000000000001452652457300141235ustar00rootroot00000000000000picobox-4.0.0/docs/_static/000077500000000000000000000000001452652457300155515ustar00rootroot00000000000000picobox-4.0.0/docs/_static/picobox.svg000066400000000000000000000071741452652457300177460ustar00rootroot00000000000000 picobox-4.0.0/docs/conf.py000066400000000000000000000015071452652457300154250ustar00rootroot00000000000000"""Sphinx configuration.""" import importlib.metadata # -- Project settings project = "Picobox" author = "Ihor Kalnytskyi" copyright = "2017, Ihor Kalnytskyi" release = importlib.metadata.version("picobox") version = ".".join(release.split(".")[:2]) # -- General settings extensions = [ "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", "sphinx_copybutton", ] source_suffix = ".rst" master_doc = "index" exclude_patterns = ["_build", "_themes"] intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} autodoc_member_order = "bysource" autodoc_mock_imports = ["flask"] autodoc_typehints = "description" # -- HTML output html_use_index = False html_show_sourcelink = False html_logo = "_static/picobox.svg" html_theme = "furo" html_theme_options = { "navigation_with_keys": True, } picobox-4.0.0/docs/index.rst000066400000000000000000000274431452652457300157760ustar00rootroot00000000000000Picobox ======= Picobox is opinionated `dependency injection`__ framework designed to be clean, pragmatic and with Python in mind. No complex graphs, no implicit injections, no type bindings, no XML configurations. .. __: https://en.wikipedia.org/wiki/Dependency_injection .. toctree:: :caption: Project :hidden: Source Bugs PyPI Why? ---- Dependency Injection (DI) design pattern is intended to decouple various parts of an application from each other. So a class can be independent of how the objects it requires are created, and hence the way we create them may be different for production and tests. One of the most easiest examples is to say that DI is essentially about writing .. code:: python def do_something(my_service): return my_service.get_val() + 42 my_service = MyService(foo, bar) do_something(my_service) instead of .. code:: python def do_something(): my_service = MyService(foo, bar) return my_service.get_val() + 42 do_something() because the latter is considered non-configurable and is harder to test. In Python, however, dependency injection is not a big deal due to its dynamic nature and duck typing: anything could be defined anytime and passed anywhere. Due to that reason (and maybe some others) DI frameworks aren't popular among Python community, though they may be handy in some cases. One of such cases is code decoupling when we want to create and use objects in different places, preserving clean interface and avoiding global variables. Having all these considerations in mind, Picobox was born. Quickstart ---------- Picobox provides ``Box`` class that acts as a container for objects you want to deal with. You can put, you can get, you can pass them around. .. code:: python import picobox box = picobox.Box() box.put("foo", 42) @box.pass_("foo") def spam(foo): return foo @box.pass_("foo", as_="bar") def eggs(bar): return bar print(box.get("foo")) # ==> 42 print(spam()) # ==> 42 print(eggs()) # ==> 42 One of the key principles is `not to break` existing code. That's why Picobox does not change function signature and injects dependencies as if they are defaults. .. code:: python print(spam()) # ==> 42 print(spam(13)) # ==> 13 print(spam(foo=99)) # ==> 99 Another key principle is that ``pass_()`` resolves dependencies lazily which means you can inject them everywhere you need and define them much later. The only rule is to define them before calling the function. .. code:: python import picobox box = picobox.Box() @box.pass_("foo") def spam(foo): return foo print(spam(13)) # ==> 13 print(spam()) # ==> KeyError: 'foo' box.put("foo", 42) print(spam()) # ==> 42 The value to inject is not necessarily an object. You can pass a factory function which will be used to produce a dependency. A factory function has no arguments, and is assumed to have all the context it needs to work. .. code:: python import picobox import random box = picobox.Box() box.put("foo", factory=lambda: random.choice(["spam", "eggs"])) @box.pass_("foo") def get_foo(foo): return foo print(get_foo()) # ==> spam print(get_foo()) # ==> eggs print(get_foo()) # ==> eggs print(get_foo()) # ==> spam print(get_foo()) # ==> eggs Whereas factories are enough to implement whatever creation policy you want, there's no good in repeating yourself again and again. That's why Picobox introduces `scope` concept. Scope is a way to say whether you want to share dependencies in some execution context or not. For instance, you may want to share it globally (singleton) or create only one instance per thread (threadlocal). .. code:: python import picobox import random import threading box = picobox.Box() box.put("foo", factory=random.random, scope=picobox.threadlocal) box.put("bar", factory=random.random, scope=picobox.singleton) @box.pass_("foo") def spam(foo): print(foo) @box.pass_("bar") def eggs(bar): print(bar) # prints # > 0.9464005851114538 # > 0.8585111290081737 for _ in range(2): threading.Thread(target=spam).start() # prints # > 0.5333214411659912 # > 0.5333214411659912 for _ in range(2): threading.Thread(target=eggs).start() But the cherry on the cake is a so called Picobox's stack interface. ``Box`` is great to manage dependencies but it requires to be created before using. In practice it usually means you need to create it globally to get access from various places. The stack interface is called to solve this by providing general methods that will be applied to latest active box instance. .. code:: python import picobox @picobox.pass_("foo") def spam(foo): return foo box_a = picobox.Box() box_a.put("foo", 13) box_b = picobox.Box() box_b.put("foo", 42) with picobox.push(box_a): print(spam()) # ==> 13 with picobox.push(box_b): print(spam()) # ==> 42 print(spam()) # ==> 13 spam() # ==> RuntimeError: no boxes on the stack When only partial overriding is necessary, you can chain pushed box so any missed lookups will be proxied to the box one level down the stack. .. code:: python import picobox @picobox.pass_("foo") @picobox.pass_("bar") def spam(foo, bar): return foo + bar box_a = picobox.Box() box_a.put("foo", 13) box_a.put("bar", 42) box_b = picobox.Box() box_b.put("bar", 0) with picobox.push(box_a): with picobox.push(box_b, chain=True): print(spam()) # ==> 13 The stack interface is recommended way to use Picobox because it allows to switch between DI containers (boxes) on the fly. This is also the only way to test your application because patching (mocking) globally defined boxes is not a solution. .. code:: python def test_spam(): with picobox.push(picobox.Box(), chain=True) as box: box.put("foo", 42) assert spam() == 42 ``picobox.push()`` can also be used as a regular function, not only as a context manager. .. code:: python def test_spam(): box = picobox.push(picobox.Box(), chain=True) box.put("foo", 42) assert spam() == 42 picobox.pop() Every call to ``picobox.push()`` should eventually be followed by a corresponding call to ``picobox.pop()`` to remove the box from the top of the stack, when you are done with it. .. note:: Dependency Injection is usually used in applications, not libraries, to wire things together. Occasionally such need may come in libraries too, so picobox provides a :class:`picobox.Stack` class to create an independent non overlapping stack with boxes suitable to be used in such cases. Just create a global instance of stack (globals themeselves aren't bad), and use it as you'd use picobox stacked interface: .. code:: python import picobox stack = picobox.Stack() @stack.pass_("a", as_="b") def mysum(a, b): return a + b with stack.push(picobox.Box()) as box: box.put("a", 42) assert mysum(13) == 55 API reference ------------- .. module:: picobox Box ``` .. autoclass:: Box :members: ChainBox ```````` .. autoclass:: ChainBox :members: Scopes `````` .. autoclass:: Scope :members: set, get .. autodata:: singleton :annotation: .. autodata:: threadlocal :annotation: .. autodata:: contextvars :annotation: .. autodata:: noscope :annotation: .. autodata:: picobox.ext.flaskscopes.application :annotation: .. autodata:: picobox.ext.flaskscopes.request :annotation: Stacked API ``````````` .. autoclass:: Stack :members: .. autofunction:: push .. autofunction:: pop .. autofunction:: put .. autofunction:: get .. autofunction:: pass_ Release Notes ------------- .. note:: Picobox follows `Semantic Versioning `_ which means backward incompatible changes will be released along with bumping major version component. 4.0.0 ````` Released on Nov 20, 2023. * **BREAKING**: The ``picobox.contrib`` package is renamed into ``picobox.ext``. * Add ``Python 3.12`` support. * Drop ``Python 3.7`` support. It reached its end-of-life recently. * Fix ``@picobox.pass_()`` decorator issue when it was shadowing a return type of the wrapped function breaking code completion in some LSP servers. * Fix ``picobox.push()`` context manager issue when it wasn't announcing properly its return type breaking code completion in some LSP servers for the returned object. * Fix ``Box.put()`` and ``picobox.put()`` to require either ``value`` or ``factory`` argument. Previously, they could have been invoked with ``key`` argument only, which makes no sense and causes runtime issues later on. 3.0.0 ````` Released on Apr 02, 2023. * Add ``Python 3.10`` & ``Python 3.11`` support. * Drop ``Python 2.7`` support. It's dead for more than a year anyway. Those who want to use picobox with ``Python 2`` should stick with ``2.x`` branch. * Drop ``Python 3.4``, ``Python 3.5`` and ``Python 3.6`` support. They reached their end-of-life and are not maintained anymore. * Add type annotations to public interface. Now users can use ``mypy`` to leverage type checking in their code base. * Make some parameters keyword-only: ``factory`` and ``scope`` in ``Box.put()``, ``as_`` in ``Box.pass_()`` and ``chain`` in ``picobox.push()``. * Use `PEP 621 `_ ``pyproject.toml`` in a so-called source distribution. 2.2.0 ````` Released on Dec 24, 2018. * Fix ``picobox.singleton``, ``picobox.threadlocal`` & ``picobox.contextvars`` scopes so they do not fail with unexpected exception when non-string formattable missing key is passed. * Add ``picobox.contrib.flaskscopes`` module with *application* and *request* scopes for Flask web framework. * Add ``picobox.Stack`` class to create stacks with boxes on demand. Might be useful for third-party developers who want to use picobox yet avoid collisions with main application developers. 2.1.0 ````` Released on Sep 25, 2018. * Add ``picobox.contextvars`` scope (python 3.7 and above) that can be used in asyncio applications to have a separate set of dependencies in all coroutines of the same task. * Fix ``picobox.threadlocal`` issue when it was impossible to use any hashable key other than ``str``. * Nested ``picobox.pass_`` calls are now squashed into one in order to improve runtime performance. * Add ``Python 2.7`` support. 2.0.0 ````` Released on Mar 18, 2018. * ``picobox.push()`` can now be used as a regular function as well, not only as a context manager. This is a breaking change because from now one a box is pushed on stack immediately when calling ``picobox.push()``, no need to wait for ``__enter__()`` to be called. * New ``picobox.pop()`` function, that pops the box from the top of the stack. * Fixed a potential race condition on concurrent calls to ``picobox.push()`` that may occur in non-CPython implementations. 1.1.0 ````` Released on Dec 19, 2017. * New ``ChainBox`` class that can be used similar to ``ChainMap`` but for boxes. This basically means from now on you can group few boxes into one view, and use that view to look up dependencies. * New ``picobox.push()`` argument called ``chain`` that can be used to look up keys down the stack on misses. 1.0.0 ````` Released on Nov 25, 2017. * First public release with initial bunch of features. picobox-4.0.0/docs/requirements.txt000066400000000000000000000000751452652457300174110ustar00rootroot00000000000000sphinx == 7.2.6 sphinx-copybutton == 0.5.2 furo == 2023.9.10 picobox-4.0.0/examples/000077500000000000000000000000001452652457300150115ustar00rootroot00000000000000picobox-4.0.0/examples/cascade/000077500000000000000000000000001452652457300163745ustar00rootroot00000000000000picobox-4.0.0/examples/cascade/example.py000066400000000000000000000010231452652457300203750ustar00rootroot00000000000000import picobox def spam(): return eggs() def eggs(): return rice() @picobox.pass_("secret") def rice(secret): print(secret) with picobox.push(picobox.Box()) as box: box.put("secret", 42) # We don't need to propagate a secret down to rice which is good because # we kept interface clear (i.e. no changes in spam and eggs signatures). spam() # The other good thing is despite injection rice can explicitly receive # a secret which means its signature wasn't changed either. rice(13) picobox-4.0.0/examples/classes/000077500000000000000000000000001452652457300164465ustar00rootroot00000000000000picobox-4.0.0/examples/classes/example.py000066400000000000000000000015471452652457300204620ustar00rootroot00000000000000import picobox class Sender: def send(self, text): print(text) class Controller: # Picobox supports injections by type (key may be any hashable object), # though in this case you have to explicitly map the key onto argument # name. @picobox.pass_(Sender, as_="sender") def __init__(self, sender): self._sender = sender # Many alternative solutions support injections to __init__ only while # Picobox allows to inject arguments wherever you want. You are the # only one to decide what would be the better way. @picobox.pass_("document") def process(self, document): self._sender.send("processing " + document) box = picobox.Box() box.put(Sender, factory=Sender, scope=picobox.singleton) box.put("document", "cv.txt") with picobox.push(box): controller = Controller() controller.process() picobox-4.0.0/examples/complex/000077500000000000000000000000001452652457300164605ustar00rootroot00000000000000picobox-4.0.0/examples/complex/example.py000066400000000000000000000005521452652457300204670ustar00rootroot00000000000000import picobox @picobox.pass_("conf") def session(conf): class Session: connection = conf["connection"] return Session() @picobox.pass_("session") def compute(session): print(session.connection) box = picobox.Box() box.put("conf", {"connection": "sqlite://"}) box.put("session", factory=session) with picobox.push(box): compute() picobox-4.0.0/examples/flask/000077500000000000000000000000001452652457300161115ustar00rootroot00000000000000picobox-4.0.0/examples/flask/example.py000066400000000000000000000017241452652457300201220ustar00rootroot00000000000000import picobox from flask import Flask, jsonify, request from tools import spam app = Flask("example") @app.route("/") def index(): return jsonify({"spam": spam()}) # @app.route() internally saves wrapped function inside, so decorators order # here does matter and if you want to inject some argument using picobox, # you ought to apply @picobox.pass_ before @app.route. @app.route("/magic") @picobox.pass_("magic") def magic(magic): return jsonify({"magic": magic}) @app.before_request def serve_eggs_with_spam(): box = picobox.Box() # on requests to /eggs, override the value of magic with 'spam' if request.path == "/eggs": box.put("magic", "spam") picobox.push(box, chain=True) @app.after_request def take_spam_away(response): # pop the box from the top of the stack to remove the override picobox.pop() return response @app.route("/eggs") @picobox.pass_("magic") def eggs(magic): return jsonify({"magic": magic}) picobox-4.0.0/examples/flask/tools.py000066400000000000000000000002171452652457300176230ustar00rootroot00000000000000import picobox def spam(): return eggs() def eggs(): return rice() @picobox.pass_("magic") def rice(magic): return magic + 1 picobox-4.0.0/examples/flask/wsgi.py000066400000000000000000000013171452652457300174360ustar00rootroot00000000000000import picobox box = picobox.Box() box.put("magic", 12) # The app instance exposed via this module is used to run the app for # development (flask run) as well as for production (uWSGI, Gunicorn). # Therefore, we need to push a box without popping so it will be used # no matter which way we want to run the app. # # Examples: # # $ FLASK_APP=wsgi.py flask run # $ gunicorn wsgi:app # # Alternatively, one can push and pop the box before and after the # request (see Flask request context), but this way it would be harder # to test the app since any attempt to override dependencies in tests # will fail due to later attempt to push a new box by request hooks. picobox.push(box) from example import app # noqa picobox-4.0.0/examples/requests/000077500000000000000000000000001452652457300166645ustar00rootroot00000000000000picobox-4.0.0/examples/requests/example.py000066400000000000000000000017201452652457300206710ustar00rootroot00000000000000import concurrent.futures import threading import picobox import requests @picobox.pass_("session") def spam(session): return "thread={}; session={}; ip={}".format( threading.get_ident(), id(session), session.get("https://httpbin.org/ip").json()["origin"], ) # According to https://github.com/kennethreitz/requests/issues/2766 # requests.Session() is not thread-safe. Therefore we need to create # a separate session for each thread. box = picobox.Box() box.put("session", factory=requests.Session, scope=picobox.threadlocal) with picobox.push(box): # We have 3 threads and 10 spam calls which means there should be no more # than 3 different session instances (check session ID in the output). with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: futures = [executor.submit(spam) for _ in range(10)] for future in concurrent.futures.as_completed(futures): print(future.result()) picobox-4.0.0/pyproject.toml000066400000000000000000000037651452652457300161220ustar00rootroot00000000000000[build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "picobox" description = "Dependency injection framework designed with Python in mind." readme = "README.rst" requires-python = ">=3.8" license = "MIT" authors = [{ name = "Ihor Kalnytskyi", email = "ihor@kalnytskyi.com" }] classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries", ] dynamic = ["version"] [project.urls] Documentation = "https://picobox.readthedocs.io" Source = "https://github.com/ikalnytskyi/picobox" Bugs = "https://github.com/ikalnytskyi/picobox/issues" [tool.hatch.version] source = "vcs" [tool.hatch.envs.test] dependencies = ["pytest", "flask"] scripts.run = "python -m pytest --strict-markers {args:-vv}" [tool.hatch.envs.lint] detached = true dependencies = ["ruff == 0.1.6"] scripts.run = ["ruff check {args:.}", "ruff format --check --diff {args:.}"] [tool.hatch.envs.docs] pre-install-commands = ["python -m pip install -r docs/requirements.txt"] scripts.run = "sphinx-build -W -b html docs docs/_build/" [tool.ruff] select = [ "F", "E", "W", "I", "D", "UP", "S", "FBT", "B", "C4", "DTZ", "T10", "ISC", "PIE", "T20", "PYI", "PT", "RET", "SLF", "SIM", "TCH", "ERA", "RUF", ] ignore = ["D203", "D213", "D401", "S101", "B904", "ISC001", "PT011", "SIM117"] line-length = 100 [tool.ruff.isort] known-first-party = ["picobox"] [tool.ruff.per-file-ignores] "examples/*" = ["I", "D", "T20"] "tests/*" = ["D"] picobox-4.0.0/src/000077500000000000000000000000001452652457300137625ustar00rootroot00000000000000picobox-4.0.0/src/picobox/000077500000000000000000000000001452652457300154255ustar00rootroot00000000000000picobox-4.0.0/src/picobox/__init__.py000066400000000000000000000006471452652457300175450ustar00rootroot00000000000000"""Dependency injection framework designed with Python in mind.""" from ._box import Box, ChainBox from ._scopes import Scope, contextvars, noscope, singleton, threadlocal from ._stack import Stack, get, pass_, pop, push, put __all__ = [ "Box", "ChainBox", "Scope", "singleton", "threadlocal", "contextvars", "noscope", "Stack", "push", "pop", "put", "get", "pass_", ] picobox-4.0.0/src/picobox/_box.py000066400000000000000000000241061452652457300167310ustar00rootroot00000000000000"""Box container.""" import functools import inspect import threading import typing as t from . import _scopes # Missing is a special sentinel object that's used to indicate a value is # missing when "None" is a valid input. It's important to define a human # readable "__repr__" because its value is used in function signatures in # API reference (see docs). class _unset: def __repr__(self): return "" _unset = _unset() class Box: """Box is a dependency injection (DI) container. DI container is an object that contains any amount of factories, one for each dependency apart. Dependency, on the other hand, is an ordinary instance or value the container needs to provide on demand. Thanks to scopes, the class keeps track of produced dependencies and knows exactly when to reuse them or when to create new ones. That is to say each scope defines a set of rules for when to reuse dependencies. Here's a minimal example of how a Box instance can be used:: import picobox box = picobox.Box() box.put('magic', 42) @box.pass_('magic') def do(magic): return magic + 1 assert box.get('magic') == 42 assert do(13) == 14 assert do() == 43 """ def __init__(self): self._store = {} self._scope_instances = {} self._lock = threading.RLock() def put( self, key: t.Hashable, value: t.Any = _unset, *, factory: t.Callable[[], t.Any] = _unset, scope: t.Type[_scopes.Scope] = _unset, ) -> None: """Define a dependency (aka service) within the box instance. A dependency can be expressed either directly, by passing a concrete `value`, or via `factory` function. A `factory` may be accompanied by `scope` that defines a set of rules for when to create a new dependency instance and when to reuse existing one. If `scope` is not passed, no scope is assumed which means produce a new instance each time it's requested. :param key: A key under which to put a dependency. Can be any hashable object, but string is recommended. :param value: A dependency to be stored within a box under `key` key. Can be any object. A syntax sugar for ``factory=lambda: value``. :param factory: A factory function to produce a dependency when needed. Must be callable with no arguments. :param scope: A scope to keep track of produced dependencies. Must be a class that implements :class:`Scope` interface. :raises ValueError: If both `value` and `factory` are passed. """ if value is _unset and factory is _unset: raise TypeError("Box.put() missing 1 required argument: either 'value' or 'factory'") if value is not _unset and factory is not _unset: raise TypeError("Box.put() takes either 'value' or 'factory', not both") if value is not _unset and scope is not _unset: raise TypeError("Box.put() takes 'scope' when 'factory' provided") # Value is a syntax sugar Box supports to store objects "As Is" # with singleton scope. In other words it's essentially the same # as one pass "factory=lambda: value". Alternatively, Box could # have just one factory argument and check it for callable, but # in this case it wouldn't support values which are callable by # its nature. if value is not _unset: def factory(): return value scope = _scopes.singleton # If scope is not explicitly passed, Box assumes "No Scope" # scope which means each time someone asks a box to retrieve a # value it would use a factory function. elif scope is _unset: scope = _scopes.noscope # Convert a given scope class into a scope instance. Since key # is uniquely defined among all scopes within the same box, it's # safe to reuse already created scope instance in order to avoid # memory consumption when a lot of objects with the same scope # are put into a box. try: scope = self._scope_instances[scope] except KeyError: scope = self._scope_instances.setdefault(scope, scope()) # Despite "dict" is thread-safe in CPython (due to GIL), it's not # guaranteed by the language itself and may not be the case among # alternative implementations. with self._lock: self._store[key] = (scope, factory) def get(self, key: t.Hashable, default: t.Any = _unset) -> t.Any: """Retrieve a dependency (aka service) out of the box instance. The process involves creation of requested dependency by calling an associated `factory` function, and then returning result back to the caller code. If a dependency is `scoped`, there's a chance for an existing instance to be returned instead. :param key: A key to retrieve a dependency. Must be the one used when calling :meth:`.put` method. :param default: (optional) A fallback value to be returned if there's no `key` in the box. If not passed, `KeyError` is raised. :raises KeyError: If no dependencies saved under `key` in the box. """ # If nothing was put into a box under "key", Box follows mapping # interface and raises KeyError, unless some default value has been # passed as the fallback value. try: scope, factory = self._store[key] except KeyError: if default is _unset: raise return default # If something was put into a box under "key", Box tries to retrieve a # value. If it does not exist for current execution context, Box uses a # factory function to create one. For implementation details below # please refer to double-checked locking design pattern. try: value = scope.get(key) except KeyError: with self._lock: try: value = scope.get(key) except KeyError: value = factory() scope.set(key, value) return value def pass_(self, key: t.Hashable, *, as_: str = _unset): r"""Pass a dependency to a function if nothing explicitly passed. The decorator implements late binding which means it does not require to have a dependency instance in the box before applying. The instance will be looked up when a decorated function is called. Other important property is that it doesn't change a signature of decorated function preserving a way to explicitly pass arguments ignoring injections. :param key: A key to retrieve a dependency. Must be the one used when calling :meth:`.put` method. :param as\_: (optional) Bind a dependency associated with `key` to a function argument named `as_`. If not passed, the same as `key`. :raises KeyError: If no dependencies saved under `key` in the box. """ def decorator(fn): # If pass_ decorator is called second time (or more), we can squash # the calls into one and reduce runtime costs of injection. if hasattr(fn, "__dependencies__"): fn.__dependencies__.append((key, as_)) return fn @functools.wraps(fn) def wrapper(*args, **kwargs): signature = inspect.signature(fn) arguments = signature.bind_partial(*args, **kwargs) for key, as_ in wrapper.__dependencies__: if as_ is _unset: as_ = key # One of picobox core principles is to supply dependencies # if and only if they weren't passed explicitly by the # caller code. A rationale behind is to be compatible with # calls written prior picobox integration. if as_ not in arguments.arguments: kwargs[as_] = self.get(key) return fn(*args, **kwargs) wrapper.__dependencies__ = [(key, as_)] return wrapper return decorator class ChainBox(Box): """ChainBox groups multiple boxes together to create a single view. ChainBox for boxes is essentially the same as :class:`~collections.ChainMap` for mappings. It mimics :class:`Box` interface and hence can substitute one but provides a way to look up dependencies in underlying boxes. Here's a minimal example of how ChainBox instance can be used:: box_a = picobox.Box() box_a.put('magic_a', 42) box_b = picobox.Box() box_b.put('magic_a', factory=lambda: 10) box_b.put('magic_b', factory=lambda: 13) chainbox = picobox.ChainBox(box_a, box_b) @chainbox.pass_('magic_a') @chainbox.pass_('magic_b') def do(magic_a, magic_b): return magic_a + magic_b assert chainbox.get('magic_b') == 13 assert do() == 55 :param boxes: (optional) A list of boxes to lookup into. If no boxes are passed, an empty box is created and used as underlying box instead. .. versionadded:: 1.1 """ def __init__(self, *boxes: Box): self._boxes = boxes or (Box(),) def put( self, key: t.Hashable, value: t.Any = _unset, *, factory: t.Callable[[], t.Any] = _unset, scope: t.Type[_scopes.Scope] = _unset, ) -> None: """Same as :meth:`Box.put` but applies to first underlying box.""" return self._boxes[0].put(key, value, factory=factory, scope=scope) def get(self, key: t.Hashable, default: t.Any = _unset) -> t.Any: """Same as :meth:`Box.get` but looks up for key in underlying boxes.""" for box in self._boxes: try: return box.get(key) except KeyError: pass if default is _unset: raise KeyError(key) return default picobox-4.0.0/src/picobox/_scopes.py000066400000000000000000000054551452652457300174430ustar00rootroot00000000000000"""Scope interface and builtin implementations.""" import abc import contextvars as _contextvars import threading import typing as t class Scope(metaclass=abc.ABCMeta): """Scope is an execution context based storage interface. Execution context is a mechanism of storing and accessing data bound to a logical thread of execution. Thus, one may consider processes, threads, greenlets, coroutines, Flask requests to be examples of a logical thread. The interface provides just two methods: * :meth:`.set` - set execution context item * :meth:`.get` - get execution context item See corresponding methods for details below. """ @abc.abstractmethod def set(self, key: t.Hashable, value: t.Any) -> None: """Bind `value` to `key` in current execution context.""" @abc.abstractmethod def get(self, key: t.Hashable) -> t.Any: """Get `value` by `key` for current execution context.""" class singleton(Scope): """Share instances across application.""" def __init__(self): self._store = {} def set(self, key: t.Hashable, value: t.Any) -> None: self._store[key] = value def get(self, key: t.Hashable) -> t.Any: return self._store[key] class threadlocal(Scope): """Share instances across the same thread.""" def __init__(self): self._local = threading.local() def set(self, key: t.Hashable, value: t.Any) -> None: try: store = self._local.store except AttributeError: store = self._local.store = {} store[key] = value def get(self, key: t.Hashable) -> t.Any: try: rv = self._local.store[key] except AttributeError: raise KeyError(key) return rv class contextvars(Scope): """Share instances across the same execution context (:pep:`567`). Since `asyncio does support context variables`__, the scope could be used in asynchronous applications to share dependencies between coroutines of the same :class:`asyncio.Task`. .. __: https://docs.python.org/3/library/contextvars.html#asyncio-support .. versionadded:: 2.1 """ def __init__(self): self._store = {} def set(self, key: t.Hashable, value: t.Any) -> None: try: var = self._store[key] except KeyError: var = self._store[key] = _contextvars.ContextVar("picobox") var.set(value) def get(self, key: t.Hashable) -> t.Any: try: return self._store[key].get() except LookupError: raise KeyError(key) class noscope(Scope): """Do not share instances, create them each time on demand.""" def set(self, key: t.Hashable, value: t.Any) -> None: pass def get(self, key: t.Hashable) -> t.Any: raise KeyError(key) picobox-4.0.0/src/picobox/_stack.py000066400000000000000000000170421452652457300172470ustar00rootroot00000000000000"""Picobox API to work with a box at the top of the stack.""" import contextlib import functools import threading import typing as t from ._box import Box, ChainBox def _copy_signature(method, instance=None): # This is a workaround to overcome 'sphinx.ext.autodoc' inability to # retrieve a docstring of a bound method. Here's the trick - we create # a partial function, and autodoc can deal with partially applied # functions. if instance: method = functools.partial(method, instance) # The reason behind empty arguments is to reuse a signature of wrapped # function while preserving "__doc__", "__name__" and other accompanied # attributes. They are very helpful for troubleshooting as well as # necessary for Sphinx API reference. return functools.wraps(method, (), ()) def _create_stack_proxy(stack, empty_stack_error): """Create an object that proxies all calls to the top of the stack.""" class _StackProxy: def __getattribute__(self, name): try: return getattr(stack[-1], name) except IndexError: raise RuntimeError(empty_stack_error) return _StackProxy() @contextlib.contextmanager def _create_push_context_manager(box, pop_callback): """Create a context manager that calls something on exit.""" try: yield box finally: # Ensure the poped box is the same that was submitted by this exact # context manager. It may happen if someone messed up with order of # push() and pop() calls. Normally, push() should be used a context # manager to avoid this issue. assert pop_callback() is box class Stack: """Stack is a dependency injection (DI) container for containers (boxes). While :class:`Box` is a great way to manage dependencies, it has no means to override them. This might be handy most of all in tests, where you usually need to provide a special set of dependencies configured for test purposes. This is where :class:`Stack` comes in. It provides the very same interface Box does, but proxies all calls to a box on the top. This basically means you can define injection points once, but change dependencies on the fly by changing DI containers (boxes) on the stack. Here's a minimal example of how a stack can be used:: import picobox stack = picobox.Stack() @stack.pass_('magic') def do(magic): return magic + 1 foobox = picobox.Box() foobox.put('magic', 42) barbox = picobox.Box() barbox.put('magic', 13) with stack.push(foobox): with stack.push(barbox): assert do() == 14 assert do() == 43 .. note:: Usually you want to have only one stack instance to wire things up. That's why picobox comes with pre-created stack instance. You can work with that instance using :func:`push`, :func:`pop`, :func:`put`, :func:`get` and :func:`pass_` functions. :param name: (optional) A name of the stack. .. versionadded:: 2.2 """ def __init__(self, name: t.Optional[str] = None): self._name = name self._stack = [] self._lock = threading.Lock() # A proxy object that proxies all calls to a box instance on the top # of the stack. We need such an object to provide a set of functions # that mimic Box interface but deal with a box on the top instead. # While it's not completely necessary for `put()` and `get()`, it's # crucial for `pass_()` due to its laziness and thus late evaluation. self._topbox = _create_stack_proxy( self._stack, "No boxes found on the stack, please `.push()` a box first." ) def __repr__(self): name = self._name if not self._name: name = "0x%x" % id(self) return "" % name def push(self, box: Box, *, chain: bool = False) -> t.ContextManager[Box]: """Push a :class:`Box` instance to the top of the stack. Returns a context manager, that will automatically pop the box from the top of the stack on exit. Can also be used as a regular function, in which case it's up to callers to perform a corresponding call to :meth:`.pop`, when they are done with the box. :param box: A :class:`Box` instance to push to the top of the stack. :param chain: (optional) Look up missed keys one level down the stack. To look up through multiple levels, each level must be created with this option set to ``True``. """ # list.append() is a thread-safe operation in CPython, yet the safety # is not guranteed by the language itself. So the lock is used here to # ensure the code works properly even when running on alternative # implementations. with self._lock: if chain and self._stack: box = ChainBox(box, self._stack[-1]) self._stack.append(box) return _create_push_context_manager(self._stack[-1], self._stack.pop) def pop(self) -> Box: """Pop the box from the top of the stack. Should be called once for every corresponding call to :meth:`.push` in order to remove the box from the top of the stack, when a caller is done with it. .. note:: Normally :meth:`.push` should be used a context manager, in which case the box on the top is removed automatically on exit from the block (i.e. no need to call :meth:`.pop` manually). :return: a removed box :raises IndexError: If the stack is empty and there's nothing to pop. """ # list.append() is a thread-safe operation in CPython, yet the safety # is not guranteed by the language itself. So the lock is used here to # ensure the code works properly even when running on alternative # implementations. with self._lock: return self._stack.pop() @_copy_signature(Box.put) def put(self, *args, **kwargs): """The same as :meth:`Box.put` but for a box at the top.""" return self._topbox.put(*args, **kwargs) @_copy_signature(Box.get) def get(self, *args, **kwargs): """The same as :meth:`Box.get` but for a box at the top.""" return self._topbox.get(*args, **kwargs) @_copy_signature(Box.pass_) def pass_(self, *args, **kwargs): """The same as :meth:`Box.pass_` but for a box at the top.""" return Box.pass_(self._topbox, *args, **kwargs) _instance = Stack("shared") @_copy_signature(Stack.push, _instance) def push(*args, **kwargs): """The same as :meth:`Stack.push` but for a shared stack instance. .. versionadded:: 1.1 ``chain`` parameter """ return _instance.push(*args, **kwargs) @_copy_signature(Stack.pop, _instance) def pop(*args, **kwargs): """The same as :meth:`Stack.pop` but for a shared stack instance. .. versionadded:: 2.0 """ return _instance.pop(*args, **kwargs) @_copy_signature(Stack.put, _instance) def put(*args, **kwargs): """The same as :meth:`Stack.put` but for a shared stack instance.""" return _instance.put(*args, **kwargs) @_copy_signature(Stack.get, _instance) def get(*args, **kwargs): """The same as :meth:`Stack.get` but for a shared stack instance.""" return _instance.get(*args, **kwargs) @_copy_signature(Stack.pass_, _instance) def pass_(*args, **kwargs): """The same as :meth:`Stack.pass_` but for a shared stack instance.""" return _instance.pass_(*args, **kwargs) picobox-4.0.0/src/picobox/ext/000077500000000000000000000000001452652457300162255ustar00rootroot00000000000000picobox-4.0.0/src/picobox/ext/__init__.py000066400000000000000000000000531452652457300203340ustar00rootroot00000000000000"""Lock, Stock and Two Smoking Barrels.""" picobox-4.0.0/src/picobox/ext/flaskscopes.py000066400000000000000000000036701452652457300211220ustar00rootroot00000000000000"""Scopes for Flask framework.""" import uuid import flask import picobox class _flaskscope(picobox.Scope): """A base class for Flask scopes.""" _store = None def __init__(self): # Both application and request scopes are merely proxies to # corresponding storage objects in Flask. This means multiple # scope instances will share the same storage object under the # hood, and this is not what we want. So we need to generate # some unique key per scope instance and use that key to # distinguish dependencies stored by different scope instances. self._uuid = str(uuid.uuid4()) def set(self, key, value): try: dependencies = self._store.__dependencies__ except AttributeError: dependencies = self._store.__dependencies__ = {} try: dependencies = dependencies[self._uuid] except KeyError: dependencies = dependencies.setdefault(self._uuid, {}) dependencies[key] = value def get(self, key): try: rv = self._store.__dependencies__[self._uuid][key] except (AttributeError, KeyError): raise KeyError(key) return rv class application(_flaskscope): """Share instances across the same Flask (HTTP) application. In most cases can be used interchangeably with :class:`picobox.singleton` scope. Comes around when you have `multiple Flask applications`__ and you want to have independent instances for each Flask application, despite the fact they are running in the same WSGI context. .. __: http://flask.pocoo.org/docs/1.0/patterns/appdispatch/ .. versionadded:: 2.2 """ @property def _store(self): return flask.current_app class request(_flaskscope): """Share instances across the same Flask (HTTP) request. .. versionadded:: 2.2 """ @property def _store(self): return flask.g picobox-4.0.0/src/picobox/py.typed000066400000000000000000000000001452652457300171120ustar00rootroot00000000000000picobox-4.0.0/tests/000077500000000000000000000000001452652457300143355ustar00rootroot00000000000000picobox-4.0.0/tests/conftest.py000066400000000000000000000014701452652457300165360ustar00rootroot00000000000000"""Setup pytest environment.""" import pytest import picobox @pytest.fixture( params=[ 42, "42", 42.42, True, None, (1, None, True), object(), ], ) def hashable_value(request): return request.param @pytest.fixture( params=[ 42, "42", 42.42, True, None, {"a": 1, "b": 2}, {"a", "b", "c"}, [1, 2, "c"], (1, None, True), object(), lambda: 42, ], ) def any_value(request): return request.param @pytest.fixture() def supported_key(hashable_value): return hashable_value @pytest.fixture() def supported_value(any_value): return any_value @pytest.fixture(params=[picobox.Box, picobox.ChainBox]) def boxclass(request): return request.param picobox-4.0.0/tests/ext/000077500000000000000000000000001452652457300151355ustar00rootroot00000000000000picobox-4.0.0/tests/ext/test_flaskscopes.py000066400000000000000000000101631452652457300210640ustar00rootroot00000000000000"""Test Flask scopes.""" import flask import pytest from picobox.ext import flaskscopes @pytest.fixture() def appscope(): return flaskscopes.application() @pytest.fixture() def reqscope(): return flaskscopes.request() @pytest.fixture() def flaskapp(): return flask.Flask("test") @pytest.fixture() def appcontext(flaskapp): return flaskapp.app_context @pytest.fixture() def reqcontext(flaskapp): return flaskapp.test_request_context @pytest.mark.parametrize( ("scopename", "ctx"), [ ("appscope", "appcontext"), ("reqscope", "reqcontext"), ], ) def test_scope_set(request, scopename, ctx, supported_key, supported_value): scope = request.getfixturevalue(scopename) ctxfn = request.getfixturevalue(ctx) with ctxfn(): scope.set(supported_key, supported_value) assert scope.get(supported_key) is supported_value @pytest.mark.parametrize( "scopename", [ "appscope", "reqscope", ], ) def test_scope_set_nocontext(request, scopename): scope = request.getfixturevalue(scopename) with pytest.raises(RuntimeError) as excinfo: scope.set("the-key", "the-value") excinfo.match("Working outside of application context.") @pytest.mark.parametrize( ("scopename", "ctx"), [ ("appscope", "appcontext"), ("reqscope", "reqcontext"), ], ) def test_scope_set_value_overwrite(request, scopename, ctx): scope = request.getfixturevalue(scopename) ctxfn = request.getfixturevalue(ctx) value = object() with ctxfn(): scope.set("the-key", value) assert scope.get("the-key") is value scope.set("the-key", "overwrite") assert scope.get("the-key") == "overwrite" @pytest.mark.parametrize( ("scopename", "ctx"), [ ("appscope", "appcontext"), ("reqscope", "reqcontext"), ], ) def test_scope_get_keyerror(request, scopename, ctx, supported_key): scope = request.getfixturevalue(scopename) ctxfn = request.getfixturevalue(ctx) with pytest.raises(KeyError, match=repr(supported_key)): with ctxfn(): scope.get(supported_key) @pytest.mark.parametrize( ("scopename", "ctx"), [ ("appscope", "appcontext"), ("reqscope", "reqcontext"), ], ) def test_scope_state_not_leaked(request, scopename, ctx): ctxfn = request.getfixturevalue(ctx) scope_a = request.getfixturevalue(scopename) value_a = object() scope_b = type(scope_a)() value_b = object() with ctxfn(): scope_a.set("the-key", value_a) assert scope_a.get("the-key") is value_a with pytest.raises(KeyError, match="the-key"): scope_b.get("the-key") scope_b.set("the-key", value_b) assert scope_b.get("the-key") is value_b scope_a.set("the-key", value_a) assert scope_a.get("the-key") is value_a @pytest.mark.parametrize( ("scopename", "ctx"), [ ("appscope", "appcontext"), ], ) def test_scope_value_shared(request, scopename, ctx): scope = request.getfixturevalue(scopename) ctxfn = request.getfixturevalue(ctx) value = object() with ctxfn(): scope.set("the-key", value) with ctxfn(): assert scope.get("the-key") is value @pytest.mark.parametrize( ("scopename", "ctx"), [ ("reqscope", "reqcontext"), ], ) def test_scope_value_not_shared(request, scopename, ctx): scope = request.getfixturevalue(scopename) ctxfn = request.getfixturevalue(ctx) with ctxfn(): scope.set("the-key", "the-value") with pytest.raises(KeyError, match="the-key"): with ctxfn(): scope.get("the-key") def test_scope_value_not_shared_between_apps(appscope): eggs = flask.Flask("eggs") rice = flask.Flask("rice") value = object() with eggs.app_context(): appscope.set("the-key", value) with eggs.app_context(): assert appscope.get("the-key") is value with pytest.raises(KeyError, match="the-key"): with rice.app_context(): appscope.get("the-key") with eggs.app_context(): assert appscope.get("the-key") is value picobox-4.0.0/tests/test_box.py000066400000000000000000000275761452652457300165570ustar00rootroot00000000000000"""Test picobox class.""" import collections import inspect import itertools import sys import traceback import pytest import picobox def test_box_put_key(boxclass, supported_key): testbox = boxclass() testbox.put(supported_key, "the-value") assert testbox.get(supported_key) == "the-value" def test_box_put_value(boxclass, supported_value): testbox = boxclass() testbox.put("the-key", supported_value) assert testbox.get("the-key") is supported_value def test_box_put_factory(boxclass): testbox = boxclass() testbox.put("the-key", factory=object) objects = [testbox.get("the-key") for _ in range(10)] assert len(objects) == 10 assert len(set(map(id, objects))) == 10 def test_box_put_factory_singleton_scope(boxclass): testbox = boxclass() testbox.put("the-key", factory=object, scope=picobox.singleton) objects = [testbox.get("the-key") for _ in range(10)] assert len(objects) == 10 assert len(set(map(id, objects))) == 1 def test_box_put_factory_custom_scope(boxclass): class namespacescope(picobox.Scope): def __init__(self): self._store = collections.defaultdict(dict) def set(self, key, value): self._store[namespace][key] = value def get(self, key): return self._store[namespace][key] testbox = boxclass() testbox.put("the-key", factory=object, scope=namespacescope) objects = [] namespace = "one" objects.extend( [ testbox.get("the-key"), testbox.get("the-key"), ] ) namespace = "two" objects.extend( [ testbox.get("the-key"), testbox.get("the-key"), ] ) assert len(objects) == 4 assert len(set(map(id, objects[:2]))) == 1 assert len(set(map(id, objects[2:]))) == 1 assert len(set(map(id, objects))) == 2 def test_box_put_factory_dependency(boxclass): testbox = boxclass() @testbox.pass_("a") def fn(a): return a + 1 testbox.put("a", 13) testbox.put("b", factory=fn) assert testbox.get("b") == 14 def test_box_put_value_factory_required(boxclass): testbox = boxclass() with pytest.raises(TypeError) as excinfo: testbox.put("the-key") assert str(excinfo.value) == ( "Box.put() missing 1 required argument: either 'value' or 'factory'" ) def test_box_put_value_and_factory(boxclass): testbox = boxclass() with pytest.raises(TypeError) as excinfo: testbox.put("the-key", 42, factory=object) assert str(excinfo.value) == "Box.put() takes either 'value' or 'factory', not both" def test_box_put_value_and_scope(boxclass): testbox = boxclass() with pytest.raises(TypeError) as excinfo: testbox.put("the-key", 42, scope=picobox.threadlocal) assert str(excinfo.value) == "Box.put() takes 'scope' when 'factory' provided" def test_box_get_keyerror(boxclass): testbox = boxclass() with pytest.raises(KeyError) as excinfo: testbox.get("the-key") assert str(excinfo.value) == "'the-key'" def test_box_get_default(boxclass): testbox = boxclass() sentinel = object() assert testbox.get("the-key", sentinel) is sentinel @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1,), {"b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"b": 2, "c": 3}, 15), ], ) def test_box_pass_a(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("a", 10) @testbox.pass_("a") def fn(a, b, c): return a + b + c assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"c": 3}, 14), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "c": 3}, 14), ], ) def test_box_pass_b(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("b", 10) @testbox.pass_("b") def fn(a, b, c): return a + b + c assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 13), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 13), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 13), ], ) def test_box_pass_c(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("c", 10) @testbox.pass_("c") def fn(a, b, c): return a + b + c assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 13), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 13), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 13), ], ) def test_box_pass_c_default(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("c", 10) @testbox.pass_("c") def fn(a, b, c=20): return a + b + c assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"c": 3}, 104), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "c": 3}, 104), ((), {"b": 2, "c": 3}, 15), ((), {"c": 3}, 113), ], ) def test_box_pass_ab(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("a", 10) testbox.put("b", 100) @testbox.pass_("a") @testbox.pass_("b") def fn(a, b, c): return a + b + c assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 103), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 103), ((1,), {"c": 3}, 14), ((1,), {}, 111), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 103), ((), {"a": 1, "c": 3}, 14), ((), {"a": 1}, 111), ], ) def test_box_pass_bc(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("b", 10) testbox.put("c", 100) @testbox.pass_("b") @testbox.pass_("c") def fn(a, b, c): return a + b + c assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 103), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 103), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 103), ((), {"b": 2, "c": 3}, 15), ((), {"b": 2}, 112), ], ) def test_box_pass_ac(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("a", 10) testbox.put("c", 100) @testbox.pass_("a") @testbox.pass_("c") def fn(a, b, c): return a + b + c assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 1003), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 1003), ((1,), {"c": 3}, 104), ((1,), {}, 1101), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 1003), ((), {"a": 1, "c": 3}, 104), ((), {"a": 1}, 1101), ((), {}, 1110), ], ) def test_box_pass_abc(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("a", 10) testbox.put("b", 100) testbox.put("c", 1000) @testbox.pass_("a") @testbox.pass_("b") @testbox.pass_("c") def fn(a, b, c): return a + b + c assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"c": 3}, 14), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "c": 3}, 14), ], ) def test_box_pass_d_as_b(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("d", 10) @testbox.pass_("d", as_="b") def fn(a, b, c): return a + b + c assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1,), {}, 1), ((), {"x": 1}, 1), ((), {}, 42), ], ) def test_box_pass_method(args, kwargs, rv, boxclass): testbox = boxclass() testbox.put("x", 42) class Foo: @testbox.pass_("x") def __init__(self, x): self.x = x assert Foo(*args, **kwargs).x == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((0,), {}, 41), ((), {"x": 0}, 41), ((), {}, 42), ], ) def test_box_pass_key_type(args, kwargs, rv, boxclass): class key: pass testbox = boxclass() testbox.put(key, 1) @testbox.pass_(key, as_="x") def fn(x): return x + 41 assert fn(*args, **kwargs) == rv def test_box_pass_unexpected_argument(boxclass): testbox = boxclass() testbox.put("d", 10) @testbox.pass_("d") def fn(a, b): return a + b with pytest.raises(TypeError) as excinfo: fn(1, 2) expected = "fn() got an unexpected keyword argument 'd'" if sys.version_info >= (3, 10): expected = f"test_box_pass_unexpected_argument..{expected}" assert str(excinfo.value) == expected def test_box_pass_keyerror(boxclass): testbox = boxclass() @testbox.pass_("b") def fn(a, b): return a + b with pytest.raises(KeyError) as excinfo: fn(1) assert str(excinfo.value) == "'b'" def test_box_pass_optimization(boxclass, request): testbox = boxclass() testbox.put("a", 1) testbox.put("b", 1) testbox.put("d", 1) @testbox.pass_("a") @testbox.pass_("b") @testbox.pass_("d", as_="c") def fn(a, b, c): backtrace = list( itertools.dropwhile( lambda frame: frame[2] != request.function.__name__, traceback.extract_stack(), ) ) return backtrace[1:-1] assert len(fn()) == 1 def test_box_pass_optimization_complex(boxclass, request): testbox = boxclass() testbox.put("a", 1) testbox.put("b", 1) testbox.put("c", 1) testbox.put("d", 1) def passthrough(fn): def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper @testbox.pass_("a") @testbox.pass_("b") @passthrough @testbox.pass_("c") @testbox.pass_("d") def fn(a, b, c, d): backtrace = list( itertools.dropwhile( lambda frame: frame[2] != request.function.__name__, traceback.extract_stack(), ) ) return backtrace[1:-1] assert len(fn()) == 3 def test_chainbox_put_changes_box(): testbox = picobox.Box() testchainbox = picobox.ChainBox(testbox) with pytest.raises(KeyError, match="the-key"): testchainbox.get("the-key") testchainbox.put("the-key", 42) assert testbox.get("the-key") == 42 def test_chainbox_get_chained(): testbox_a = picobox.Box() testbox_a.put("the-key", 42) testbox_b = picobox.Box() testbox_b.put("the-key", 13) testbox_b.put("the-pin", 12) testchainbox = picobox.ChainBox(testbox_a, testbox_b) assert testchainbox.get("the-key") == 42 assert testchainbox.get("the-pin") == 12 def test_chainbox_isinstance_box(): assert isinstance(picobox.ChainBox(), picobox.Box) @pytest.mark.parametrize( "name", [name for name, _ in inspect.getmembers(picobox.Box) if not name.startswith("_")], ) def test_chainbox_box_interface(name): boxsignature = inspect.signature(getattr(picobox.Box(), name)) chainboxsignature = inspect.signature(getattr(picobox.ChainBox(), name)) assert boxsignature == chainboxsignature picobox-4.0.0/tests/test_scopes.py000066400000000000000000000153751452652457300172550ustar00rootroot00000000000000"""Test picobox's scopes implementations.""" import contextvars as _contextvars import threading import pytest import picobox @pytest.fixture() def singleton(): return picobox.singleton() @pytest.fixture() def threadlocal(): return picobox.threadlocal() @pytest.fixture() def contextvars(): return picobox.contextvars() @pytest.fixture() def noscope(): return picobox.noscope() @pytest.fixture() def exec_thread(): """Run a given callback in a separate OS thread.""" def executor(callback, *args, **kwargs): closure = {} def target(): try: closure["ret"] = callback(*args, **kwargs) except Exception as e: closure["exc"] = e worker = threading.Thread(target=target) worker.start() worker.join() if "exc" in closure: raise closure["exc"] return closure["ret"] return executor @pytest.fixture() def exec_coroutine(request): """Run a given coroutine function in a separate event loop.""" asyncio = pytest.importorskip("asyncio") loop = asyncio.new_event_loop() def executor(function, *args, **kwargs): if not asyncio.iscoroutinefunction(function): async def coroutine_function(*args, **kwargs): return function(*args, **kwargs) else: coroutine_function = function return loop.run_until_complete(coroutine_function(*args, **kwargs)) try: yield executor finally: loop.close() @pytest.fixture() def exec_context(): """Run a given callback in a separate context (PEP 567).""" def executor(callback, *args, **kwargs): context = _contextvars.copy_context() return context.run(callback, *args, **kwargs) return executor @pytest.mark.parametrize( "scopename", [ "singleton", "threadlocal", "contextvars", ], ) def test_scope_set(request, scopename, supported_key, supported_value): scope = request.getfixturevalue(scopename) scope.set(supported_key, supported_value) assert scope.get(supported_key) is supported_value def test_scope_set_noscope(supported_key, supported_value): scope = picobox.noscope() scope.set(supported_key, supported_value) with pytest.raises(KeyError, match=str(supported_key)): scope.get(supported_key) @pytest.mark.parametrize( "scopename", [ "singleton", "threadlocal", "contextvars", ], ) def test_scope_set_overwrite(request, scopename): scope = request.getfixturevalue(scopename) value = object() scope.set("the-key", value) assert scope.get("the-key") is value scope.set("the-key", "overwrite") assert scope.get("the-key") == "overwrite" @pytest.mark.parametrize( "scopename", [ "singleton", "threadlocal", "contextvars", "noscope", ], ) def test_scope_get_keyerror(request, scopename, supported_key): scope = request.getfixturevalue(scopename) with pytest.raises(KeyError, match=repr(supported_key)): scope.get(supported_key) @pytest.mark.parametrize( "scopename", [ "singleton", "threadlocal", "contextvars", ], ) def test_scope_state_not_leaked(request, scopename): scope_a = request.getfixturevalue(scopename) value_a = object() scope_b = type(scope_a)() value_b = object() scope_a.set("the-key", value_a) assert scope_a.get("the-key") is value_a with pytest.raises(KeyError, match="the-key"): scope_b.get("the-key") scope_b.set("the-key", value_b) assert scope_b.get("the-key") is value_b scope_a.set("the-key", value_a) assert scope_a.get("the-key") is value_a @pytest.mark.parametrize( ("scopename", "executor"), [ ("singleton", "exec_thread"), ("singleton", "exec_coroutine"), ("singleton", "exec_context"), ("threadlocal", "exec_coroutine"), ("threadlocal", "exec_context"), ], ) def test_scope_value_shared(request, scopename, executor): scope = request.getfixturevalue(scopename) value = object() exec_ = request.getfixturevalue(executor) exec_(scope.set, "the-key", value) assert exec_(scope.get, "the-key") is value @pytest.mark.parametrize( ("scopename", "executor"), [ ("threadlocal", "exec_thread"), ("contextvars", "exec_thread"), ("contextvars", "exec_coroutine"), ("contextvars", "exec_context"), ("noscope", "exec_thread"), ("noscope", "exec_coroutine"), ("noscope", "exec_context"), ], ) def test_scope_value_not_shared(request, scopename, executor): scope = request.getfixturevalue(scopename) value = object() exec_ = request.getfixturevalue(executor) exec_(scope.set, "the-key", value) with pytest.raises(KeyError, match="the-key"): exec_(scope.get, "the-key") @pytest.mark.parametrize( ("scopename", "executor"), [ ("singleton", "exec_thread"), ("singleton", "exec_coroutine"), ("singleton", "exec_context"), ("threadlocal", "exec_thread"), ("threadlocal", "exec_coroutine"), ("threadlocal", "exec_context"), ("contextvars", "exec_thread"), ("contextvars", "exec_coroutine"), ("contextvars", "exec_context"), ], ) def test_scope_value_downstack_shared(request, scopename, executor): scope = request.getfixturevalue(scopename) value = object() exec_ = request.getfixturevalue(executor) def caller(): scope.set("the-key", value) return callee() def callee(): return scope.get("the-key") assert exec_(caller) is value @pytest.mark.parametrize( ("scopename", "executor"), [ ("noscope", "exec_thread"), ("noscope", "exec_coroutine"), ("noscope", "exec_context"), ], ) def test_scope_value_downstack_not_shared(request, scopename, executor): scope = request.getfixturevalue(scopename) value = object() exec_ = request.getfixturevalue(executor) def caller(): scope.set("the-key", value) return callee() def callee(): return scope.get("the-key") with pytest.raises(KeyError, match="the-key"): exec_(caller) @pytest.mark.parametrize( ("scopename", "executor"), [ ("threadlocal", "exec_thread"), ("contextvars", "exec_thread"), ("contextvars", "exec_coroutine"), ("contextvars", "exec_context"), ], ) def test_scope_not_leaked(request, scopename, executor): scope = request.getfixturevalue(scopename) exec_ = request.getfixturevalue(executor) scope.set("a-key", "a-value") exec_(scope.set, "the-key", "the-value") with pytest.raises(KeyError, match="the-key"): scope.get("the-key") picobox-4.0.0/tests/test_stack.py000066400000000000000000000370401452652457300170570ustar00rootroot00000000000000"""Test picobox's stack interface.""" import itertools import sys import traceback import pytest import picobox @pytest.fixture(params=[picobox.Stack(), picobox]) def teststack(request): return request.param def test_box_put_key(boxclass, teststack, supported_key): testbox = boxclass() with teststack.push(testbox): teststack.put(supported_key, "the-value") assert testbox.get(supported_key) == "the-value" def test_box_put_value(boxclass, teststack, supported_value): testbox = boxclass() with teststack.push(testbox): teststack.put("the-key", supported_value) assert testbox.get("the-key") is supported_value def test_box_put_factory(boxclass, teststack): testbox = boxclass() with teststack.push(testbox): teststack.put("the-key", factory=object) objects = [testbox.get("the-key") for _ in range(10)] assert len(objects) == 10 assert len(set(map(id, objects))) == 10 def test_box_put_factory_singleton_scope(boxclass, teststack): testbox = boxclass() with teststack.push(testbox): teststack.put("the-key", factory=object, scope=picobox.singleton) objects = [testbox.get("the-key") for _ in range(10)] assert len(objects) == 10 assert len(set(map(id, objects))) == 1 def test_box_put_factory_dependency(boxclass, teststack): testbox = boxclass() @teststack.pass_("a") def fn(a): return a + 1 with teststack.push(testbox): teststack.put("a", 13) teststack.put("b", factory=fn) assert teststack.get("b") == 14 def test_box_put_value_factory_required(boxclass, teststack): testbox = boxclass() with teststack.push(testbox): with pytest.raises(TypeError) as excinfo: teststack.put("the-key") assert str(excinfo.value) == ( "Box.put() missing 1 required argument: either 'value' or 'factory'" ) def test_box_put_value_and_factory(boxclass, teststack): testbox = boxclass() with teststack.push(testbox): with pytest.raises(TypeError) as excinfo: teststack.put("the-key", 42, factory=object) assert str(excinfo.value) == "Box.put() takes either 'value' or 'factory', not both" def test_box_put_value_and_scope(boxclass, teststack): testbox = boxclass() with teststack.push(testbox): with pytest.raises(TypeError) as excinfo: teststack.put("the-key", 42, scope=picobox.threadlocal) assert str(excinfo.value) == "Box.put() takes 'scope' when 'factory' provided" def test_box_put_runtimeerror(boxclass, teststack): with pytest.raises(RuntimeError) as excinfo: teststack.put("the-key", object()) assert str(excinfo.value) == "No boxes found on the stack, please `.push()` a box first." def test_box_get_value(boxclass, teststack, supported_value): testbox = boxclass() testbox.put("the-key", supported_value) with teststack.push(testbox): assert teststack.get("the-key") is supported_value def test_box_get_keyerror(boxclass, teststack): testbox = boxclass() with teststack.push(testbox): with pytest.raises(KeyError, match="the-key"): teststack.get("the-key") def test_box_get_default(boxclass, teststack): testbox = boxclass() sentinel = object() with teststack.push(testbox): assert teststack.get("the-key", sentinel) is sentinel @pytest.mark.parametrize( "kwargs", [ {}, {"chain": False}, ], ) def test_box_get_from_top(boxclass, teststack, kwargs): testbox_a = boxclass() testbox_a.put("the-key", "a") testbox_a.put("the-pin", "a") testbox_b = boxclass() testbox_b.put("the-key", "b") with teststack.push(testbox_a): assert teststack.get("the-key") == "a" assert teststack.get("the-pin") == "a" with teststack.push(testbox_b, **kwargs): assert teststack.get("the-key") == "b" with pytest.raises(KeyError, match="the-pin"): teststack.get("the-pin") assert teststack.get("the-key") == "a" assert teststack.get("the-pin") == "a" def test_box_get_from_top_chain(boxclass, teststack): testbox_a = boxclass() testbox_a.put("the-key", "a") testbox_a.put("the-pin", "a") testbox_b = boxclass() testbox_b.put("the-key", "b") testbox_c = boxclass() testbox_c.put("the-tip", "c") with teststack.push(testbox_a): assert teststack.get("the-key") == "a" assert teststack.get("the-pin") == "a" with teststack.push(testbox_b, chain=True): assert teststack.get("the-key") == "b" assert teststack.get("the-pin") == "a" with teststack.push(testbox_c, chain=True): assert teststack.get("the-tip") == "c" assert teststack.get("the-key") == "b" assert teststack.get("the-pin") == "a" def test_box_get_runtimeerror(teststack): with pytest.raises(RuntimeError) as excinfo: teststack.get("the-key") assert str(excinfo.value) == "No boxes found on the stack, please `.push()` a box first." @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1,), {"b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"b": 2, "c": 3}, 15), ], ) def test_box_pass_a(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("a", 10) @teststack.pass_("a") def fn(a, b, c): return a + b + c with teststack.push(testbox): assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"c": 3}, 14), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "c": 3}, 14), ], ) def test_box_pass_b(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("b", 10) @teststack.pass_("b") def fn(a, b, c): return a + b + c with teststack.push(testbox): assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 13), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 13), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 13), ], ) def test_box_pass_c(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("c", 10) @teststack.pass_("c") def fn(a, b, c): return a + b + c with teststack.push(testbox): assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 13), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 13), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 13), ], ) def test_box_pass_c_default(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("c", 10) @teststack.pass_("c") def fn(a, b, c=20): return a + b + c with teststack.push(testbox): assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"c": 3}, 104), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "c": 3}, 104), ((), {"b": 2, "c": 3}, 15), ((), {"c": 3}, 113), ], ) def test_box_pass_ab(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("a", 10) testbox.put("b", 100) @teststack.pass_("a") @teststack.pass_("b") def fn(a, b, c): return a + b + c with teststack.push(testbox): assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 103), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 103), ((1,), {"c": 3}, 14), ((1,), {}, 111), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 103), ((), {"a": 1, "c": 3}, 14), ((), {"a": 1}, 111), ], ) def test_box_pass_bc(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("b", 10) testbox.put("c", 100) @teststack.pass_("b") @teststack.pass_("c") def fn(a, b, c): return a + b + c with teststack.push(testbox): assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 103), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 103), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 103), ((), {"b": 2, "c": 3}, 15), ((), {"b": 2}, 112), ], ) def test_box_pass_ac(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("a", 10) testbox.put("c", 100) @teststack.pass_("a") @teststack.pass_("c") def fn(a, b, c): return a + b + c with teststack.push(testbox): assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1, 2), {}, 1003), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"b": 2}, 1003), ((1,), {"c": 3}, 104), ((1,), {}, 1101), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "b": 2}, 1003), ((), {"a": 1, "c": 3}, 104), ((), {"a": 1}, 1101), ((), {}, 1110), ], ) def test_box_pass_abc(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("a", 10) testbox.put("b", 100) testbox.put("c", 1000) @teststack.pass_("a") @teststack.pass_("b") @teststack.pass_("c") def fn(a, b, c): return a + b + c with teststack.push(testbox): assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1, 2, 3), {}, 6), ((1, 2), {"c": 3}, 6), ((1,), {"b": 2, "c": 3}, 6), ((1,), {"c": 3}, 14), ((), {"a": 1, "b": 2, "c": 3}, 6), ((), {"a": 1, "c": 3}, 14), ], ) def test_box_pass_d_as_b(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("d", 10) @teststack.pass_("d", as_="b") def fn(a, b, c): return a + b + c with teststack.push(testbox): assert fn(*args, **kwargs) == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((1,), {}, 1), ((), {"x": 1}, 1), ((), {}, 42), ], ) def test_box_pass_method(boxclass, teststack, args, kwargs, rv): testbox = boxclass() testbox.put("x", 42) class Foo: @teststack.pass_("x") def __init__(self, x): self.x = x with teststack.push(testbox): assert Foo(*args, **kwargs).x == rv @pytest.mark.parametrize( ("args", "kwargs", "rv"), [ ((0,), {}, 41), ((), {"x": 0}, 41), ((), {}, 42), ], ) def test_box_pass_key_type(boxclass, teststack, args, kwargs, rv): class key: pass testbox = boxclass() testbox.put(key, 1) @testbox.pass_(key, as_="x") def fn(x): return x + 41 with teststack.push(testbox): assert fn(*args, **kwargs) == rv def test_box_pass_unexpected_argument(boxclass, teststack): testbox = boxclass() testbox.put("d", 10) @teststack.pass_("d") def fn(a, b): return a + b with teststack.push(testbox): with pytest.raises(TypeError) as excinfo: fn(1, 2) expected = "fn() got an unexpected keyword argument 'd'" if sys.version_info >= (3, 10): expected = f"test_box_pass_unexpected_argument..{expected}" assert str(excinfo.value) == expected def test_box_pass_keyerror(boxclass, teststack): testbox = boxclass() @teststack.pass_("b") def fn(a, b): return a + b with teststack.push(testbox): with pytest.raises(KeyError, match="b"): fn(1) def test_box_pass_runtimeerror(teststack): @teststack.pass_("a") def fn(a): return a with pytest.raises(RuntimeError) as excinfo: fn() assert str(excinfo.value) == "No boxes found on the stack, please `.push()` a box first." def test_box_pass_optimization(boxclass, teststack, request): testbox = boxclass() testbox.put("a", 1) testbox.put("b", 1) testbox.put("d", 1) @teststack.pass_("a") @teststack.pass_("b") @teststack.pass_("d", as_="c") def fn(a, b, c): backtrace = list( itertools.dropwhile( lambda frame: frame[2] != request.function.__name__, traceback.extract_stack(), ) ) return backtrace[1:-1] with teststack.push(testbox): assert len(fn()) == 1 def test_box_pass_optimization_complex(boxclass, teststack, request): testbox = boxclass() testbox.put("a", 1) testbox.put("b", 1) testbox.put("c", 1) testbox.put("d", 1) def passthrough(fn): def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper @teststack.pass_("a") @teststack.pass_("b") @passthrough @teststack.pass_("c") @teststack.pass_("d") def fn(a, b, c, d): backtrace = list( itertools.dropwhile( lambda frame: frame[2] != request.function.__name__, traceback.extract_stack(), ) ) return backtrace[1:-1] with teststack.push(testbox): assert len(fn()) == 3 def test_chainbox_put_changes_box(teststack): testbox = picobox.Box() testchainbox = picobox.ChainBox(testbox) with teststack.push(testchainbox): with pytest.raises(KeyError, match="the-key"): teststack.get("the-key") teststack.put("the-key", 42) assert testbox.get("the-key") == 42 def test_chainbox_get_chained(teststack): testbox_a = picobox.Box() testbox_a.put("the-key", 42) testbox_b = picobox.Box() testbox_b.put("the-key", 13) testbox_b.put("the-pin", 12) testchainbox = picobox.ChainBox(testbox_a, testbox_b) with teststack.push(testchainbox): assert testchainbox.get("the-key") == 42 assert testchainbox.get("the-pin") == 12 def test_stack_isolated(boxclass): testbox_a = picobox.Box() testbox_a.put("the-key", 42) testbox_b = picobox.Box() testbox_b.put("the-pin", 12) teststack_a = picobox.Stack() teststack_b = picobox.Stack() with teststack_a.push(testbox_a): with pytest.raises(RuntimeError) as excinfo: teststack_b.get("the-key") assert str(excinfo.value) == "No boxes found on the stack, please `.push()` a box first." with teststack_b.push(testbox_b): with pytest.raises(KeyError, match="the-pin"): teststack_a.get("the-pin") assert teststack_b.get("the-pin") == 12 def test_push_pop_as_regular_functions(teststack): @teststack.pass_("magic") def do(magic): return magic + 1 foobox = picobox.Box() foobox.put("magic", 42) barbox = picobox.Box() barbox.put("magic", 13) teststack.push(foobox) assert do() == 43 teststack.push(barbox) assert do() == 14 assert teststack.pop() is barbox assert teststack.pop() is foobox def test_pop_empty_stack(teststack): with pytest.raises(IndexError): teststack.pop()