diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f2651c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.coverage +.git +.pytest_cache +*.egg-info +*/__pycache__ +docs +htmlcov +venv diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b36644..bd8f390 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,4 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: CI +name: localscope on: push: @@ -13,30 +10,31 @@ on: jobs: build: - + name: Build runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8] + python-version: + - '3.8' + - '3.9' + - '3.10' + - '3.11' + - '3.12' steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }}. + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + cache: pip + - name: Install dependencies. run: pip install -r requirements.txt - - name: Build docs, lint, and test - run: make build - - name: Publish package - # Only publish with one python version - # (cf. https://github.com/pypa/gh-action-pypi-publish/issues/16#issuecomment-557827529) - if: >- - matrix.python-version == '3.8' - && github.event_name == 'push' - && startsWith(github.event.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.pypi_password }} + - name: Lint code. + run: make lint + - name: Build documentation. + run: make docs + - name: Run doctests. + run: make doctests + - name: Run tests. + run: make tests diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3fad0d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +ARG version +FROM python:${version} +WORKDIR /workdir +COPY README.rst requirements.txt setup.py ./ +RUN pip install -r requirements.txt +COPY . . diff --git a/Makefile b/Makefile index 4cb0c29..06577b9 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,39 @@ -.PHONY : clean dist docs lint tests +.PHONY : dist docs doctests docker-image lint tests -# Build documentation, lint the code, and run tests -build : setup.py docs lint tests +# Build documentation, lint the code, and run tests. +build : setup.py docs doctests lint tests python setup.py sdist twine check dist/*.tar.gz lint : flake8 + black --check . docs : - sphinx-build -b doctest . docs/_build - sphinx-build -b html . docs/_build + # Always build from scratch because of dodgy Sphinx caching. + rm -rf docs/_build + sphinx-build -nW -b html . docs/_build -clean : +doctests : + # Always build from scratch because of dodgy Sphinx caching. rm -rf docs/_build + sphinx-build -nW -b doctest . docs/_build tests : pytest -v --cov localscope --cov-report=html --cov-report=term-missing \ - --cov-fail-under=100 tests + --cov-fail-under=100 -# Build pinned requirements file +# Build pinned requirements file. requirements.txt : requirements.in setup.py pip-compile -v $< - pip-sync $@ + +# Docker versions. +VERSIONS = 3.8 3.9 3.10 3.11 3.12 +IMAGES = ${addprefix docker-image/,${VERSIONS}} + +docker-images : ${IMAGES} +${IMAGES} : docker-image/% : + docker build --build-arg version=$* -t localscope:$* . + +$(addprefix docker-shell/,${VERSIONS}) : docker-shell/% : docker-image/% + docker run --rm -it localscope:$* bash diff --git a/conf.py b/conf.py index 5910a5c..d8b2598 100644 --- a/conf.py +++ b/conf.py @@ -1,58 +1,15 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - - -# -- Project information ----------------------------------------------------- - -project = 'localscope' -copyright = '2020, Till Hoffmann' -author = 'Till Hoffmann' -master_doc = 'README' - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. +project = "localscope" +copyright = "2020, Till Hoffmann" +author = "Till Hoffmann" extensions = [ - 'sphinx.ext.napoleon', - 'sphinx.ext.doctest', - 'sphinx.ext.autodoc', + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.napoleon", +] +exclude_patterns = [ + "_build", + "*.egg-info", + "README.rst", + "venv", ] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'alabaster' - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = [] - doctest_global_setup = "from localscope import localscope" diff --git a/index.rst b/index.rst new file mode 120000 index 0000000..92cacd2 --- /dev/null +++ b/index.rst @@ -0,0 +1 @@ +README.rst \ No newline at end of file diff --git a/localscope/__init__.py b/localscope/__init__.py index 6a569ef..d16061c 100644 --- a/localscope/__init__.py +++ b/localscope/__init__.py @@ -4,88 +4,97 @@ import inspect import logging import types +from typing import Callable, Set, Optional LOGGER = logging.getLogger(__name__) -def localscope(func=None, *, predicate=None, allowed=None, allow_closure=False, _globals=None): +def localscope( + func: Optional[Callable] = None, + *, + predicate: Optional[Callable] = None, + allowed: Optional[Set[str]] = None, + allow_closure: bool = False, + _globals: Optional[Set[str]] = None, +): """ - Restrict the scope of a callable to local variables to avoid unintentional information ingress. - - Parameters - ---------- - func : callable - Callable whose scope to restrict. - predicate : callable, optional - Predicate to determine whether a global variable is allowed in the scope. Defaults to allow - any module. - allowed : sequence, optional - Names of globals that are allowed to enter the scope. - _globals : dict, internal - Globals associated with the root callable which are passed to dependent code blocks for - analysis. - - Attributes - ---------- - mfc : localscope - Decorator allowing *m*\\ odules, *f*\\ unctions, and *c*\\ lasses to enter the local scope. - - Examples - -------- - Basic example demonstrating the functionality of localscope. - - >>> a = 'hello world' - >>> @localscope - ... def print_a(): - ... print(a) - Traceback (most recent call last): - ... - ValueError: `a` is not a permitted global - - The scope of a function can be extended by providing a list of allowed exceptions. - - >>> a = 'hello world' - >>> @localscope(allowed=['a']) - ... def print_a(): - ... print(a) - >>> print_a() - hello world - - The predicate keyword argument can be used to control which `values` are allowed to enter the - scope (by default, only modules may be used in functions). - - >>> a = 'hello world' - >>> allow_strings = localscope(predicate=lambda x: isinstance(x, str)) - >>> @allow_strings - ... def print_a(): - ... print(a) - >>> print_a() - hello world - - Localscope is strict by default, but :code:`localscope.mfc` can be used to allow modules, - functions, and classes to enter the function scope: a common use case in notebooks. - - >>> class MyClass: - ... pass - >>> @localscope.mfc - ... def create_instance(): - ... return MyClass() - >>> create_instance() - - - Notes - ----- - The localscope decorator analysis the decorated function (and any dependent code blocks) at the - time of declaration because static analysis has a minimal impact on performance and it is - easier to implement. + Restrict the scope of a callable to local variables to avoid unintentional + information ingress. + + Args: + func : Callable whose scope to restrict. + predicate : Predicate to determine whether a global variable is allowed in the + scope. Defaults to allow any module. + allowed: Names of globals that are allowed to enter the scope. + _globals : Globals associated with the root callable which are passed to + dependent code blocks for analysis. + + Attributes: + mfc: Decorator allowing *m*\\ odules, *f*\\ unctions, and *c*\\ lasses to enter + the local scope. + + Examples: + + Basic example demonstrating the functionality of localscope. + + >>> a = 'hello world' + >>> @localscope + ... def print_a(): + ... print(a) + Traceback (most recent call last): + ... + ValueError: `a` is not a permitted global + + The scope of a function can be extended by providing a list of allowed + exceptions. + + >>> a = 'hello world' + >>> @localscope(allowed=['a']) + ... def print_a(): + ... print(a) + >>> print_a() + hello world + + The predicate keyword argument can be used to control which `values` are allowed + to enter the scope (by default, only modules may be used in functions). + + >>> a = 'hello world' + >>> allow_strings = localscope(predicate=lambda x: isinstance(x, str)) + >>> @allow_strings + ... def print_a(): + ... print(a) + >>> print_a() + hello world + + Localscope is strict by default, but :code:`localscope.mfc` can be used to allow + modules, functions, and classes to enter the function scope: a common use case + in notebooks. + + >>> class MyClass: + ... pass + >>> @localscope.mfc + ... def create_instance(): + ... return MyClass() + >>> create_instance() + + + Notes: + + The localscope decorator analysis the decorated function (and any dependent code + blocks) at the time of declaration because static analysis has a minimal impact + on performance and it is easier to implement. """ # Set defaults predicate = predicate or inspect.ismodule allowed = list(allowed or []) if func is None: - return ft.partial(localscope, allow_closure=allow_closure, predicate=predicate, - allowed=allowed) + return ft.partial( + localscope, + allow_closure=allow_closure, + predicate=predicate, + allowed=allowed, + ) if isinstance(func, types.FunctionType): code = func.__code__ @@ -94,13 +103,13 @@ def localscope(func=None, *, predicate=None, allowed=None, allow_closure=False, code = func # Add function arguments to the list of allowed exceptions - allowed.extend(code.co_varnames[:code.co_argcount]) + allowed.extend(code.co_varnames[: code.co_argcount]) - opnames = {'LOAD_GLOBAL'} + opnames = {"LOAD_GLOBAL"} if not allow_closure: - opnames.add('LOAD_DEREF') + opnames.add("LOAD_DEREF") - LOGGER.info('analysing instructions for %s...', func) + LOGGER.info("analysing instructions for %s...", func) for instruction in dis.get_instructions(code): LOGGER.info(instruction) name = instruction.argval @@ -110,18 +119,24 @@ def localscope(func=None, *, predicate=None, allowed=None, allow_closure=False, continue # Complain if the variable is not available if name not in _globals: - raise NameError(f'`{name}` is not in globals') + raise NameError(f"`{name}` is not in globals") # Get the value of the variable and check it against the predicate value = _globals[name] if not predicate(value): - raise ValueError(f'`{name}` is not a permitted global') - elif instruction.opname == 'STORE_DEREF': + raise ValueError(f"`{name}` is not a permitted global") + elif instruction.opname == "STORE_DEREF": allowed.append(name) - # Deal with code objects recursively after add the current arguments to the allowed exceptions + # Deal with code objects recursively after add the current arguments to the allowed + # exceptions for const in code.co_consts: if isinstance(const, types.CodeType): - localscope(const, _globals=_globals, allow_closure=True, predicate=predicate, - allowed=allowed) + localscope( + const, + _globals=_globals, + allow_closure=True, + predicate=predicate, + allowed=allowed, + ) return func diff --git a/requirements.in b/requirements.in index bbf02b9..60f170d 100644 --- a/requirements.in +++ b/requirements.in @@ -1,2 +1,11 @@ -e file:.[tests,docs] +black twine +# Additional version restrictions for Python 3.8. +alabaster <= 0.7.13 +sphinx <= 7.1.2 +sphinxcontrib-applehelp <= 1.0.4 +sphinxcontrib-devhelp <= 1.0.2 +sphinxcontrib-htmlhelp <= 2.0.1 +sphinxcontrib-qthelp <= 1.0.3 +sphinxcontrib-serializinghtml <= 1.1.5 diff --git a/requirements.txt b/requirements.txt index 13308b7..bc36cdd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,56 +1,146 @@ # -# This file is autogenerated by pip-compile -# To update, run: +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: # # pip-compile requirements.in # --e file:. # via -r requirements.in -alabaster==0.7.12 # via sphinx -attrs==20.2.0 # via pytest -babel==2.8.0 # via sphinx -bleach==3.2.1 # via readme-renderer -certifi==2020.6.20 # via requests -chardet==3.0.4 # via requests -colorama==0.4.4 # via twine -coverage==5.3 # via pytest-cov -docutils==0.16 # via readme-renderer, sphinx -flake8==3.8.4 # via localscope -idna==2.10 # via requests -imagesize==1.2.0 # via sphinx -iniconfig==1.1.1 # via pytest -jinja2==2.11.2 # via sphinx -keyring==21.4.0 # via twine -markupsafe==1.1.1 # via jinja2 -mccabe==0.6.1 # via flake8 -packaging==20.4 # via bleach, pytest, sphinx -pkginfo==1.5.0.1 # via twine -pluggy==0.13.1 # via pytest -py==1.9.0 # via pytest -pycodestyle==2.6.0 # via flake8 -pyflakes==2.2.0 # via flake8 -pygments==2.7.1 # via readme-renderer, sphinx -pyparsing==2.4.7 # via packaging -pytest-cov==2.10.1 # via localscope -pytest==6.1.1 # via localscope, pytest-cov -pytz==2020.1 # via babel -readme-renderer==27.0 # via twine -requests-toolbelt==0.9.1 # via twine -requests==2.24.0 # via requests-toolbelt, sphinx, twine -rfc3986==1.4.0 # via twine -six==1.15.0 # via bleach, packaging, readme-renderer -snowballstemmer==2.0.0 # via sphinx -sphinx==3.2.1 # via localscope -sphinxcontrib-applehelp==1.0.2 # via sphinx -sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==1.0.3 # via sphinx -sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 # via sphinx -sphinxcontrib-serializinghtml==1.1.4 # via sphinx -toml==0.10.1 # via pytest -tqdm==4.50.2 # via twine -twine==3.2.0 # via -r requirements.in -urllib3==1.25.10 # via requests -webencodings==0.5.1 # via bleach - -# The following packages are considered to be unsafe in a requirements file: -# setuptools +-e file:. + # via file:///- +alabaster==0.7.13 + # via + # -r requirements.in + # sphinx +babel==2.14.0 + # via sphinx +black==24.2.0 + # via -r requirements.in +certifi==2024.2.2 + # via requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via black +coverage[toml]==7.4.1 + # via pytest-cov +docutils==0.20.1 + # via + # readme-renderer + # sphinx +exceptiongroup==1.2.0 + # via pytest +flake8==7.0.0 + # via localscope +idna==3.6 + # via requests +imagesize==1.4.1 + # via sphinx +importlib-metadata==7.0.1 + # via + # keyring + # twine +iniconfig==2.0.0 + # via pytest +jaraco-classes==3.3.1 + # via keyring +jinja2==3.1.3 + # via sphinx +keyring==24.3.0 + # via twine +markdown-it-py==3.0.0 + # via rich +markupsafe==2.1.5 + # via jinja2 +mccabe==0.7.0 + # via flake8 +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.2.0 + # via jaraco-classes +mypy-extensions==1.0.0 + # via black +nh3==0.2.15 + # via readme-renderer +packaging==23.2 + # via + # black + # pytest + # sphinx +pathspec==0.12.1 + # via black +pkginfo==1.9.6 + # via twine +platformdirs==4.2.0 + # via black +pluggy==1.4.0 + # via pytest +pycodestyle==2.11.1 + # via flake8 +pyflakes==3.2.0 + # via flake8 +pygments==2.17.2 + # via + # readme-renderer + # rich + # sphinx +pytest==8.0.1 + # via + # localscope + # pytest-cov +pytest-cov==4.1.0 + # via localscope +readme-renderer==42.0 + # via twine +requests==2.31.0 + # via + # requests-toolbelt + # sphinx + # twine +requests-toolbelt==1.0.0 + # via twine +rfc3986==2.0.0 + # via twine +rich==13.7.0 + # via twine +snowballstemmer==2.2.0 + # via sphinx +sphinx==7.1.2 + # via + # -r requirements.in + # localscope +sphinxcontrib-applehelp==1.0.4 + # via + # -r requirements.in + # sphinx +sphinxcontrib-devhelp==1.0.2 + # via + # -r requirements.in + # sphinx +sphinxcontrib-htmlhelp==2.0.1 + # via + # -r requirements.in + # sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via + # -r requirements.in + # sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via + # -r requirements.in + # sphinx +tomli==2.0.1 + # via + # black + # coverage + # pytest +twine==5.0.0 + # via -r requirements.in +typing-extensions==4.9.0 + # via black +urllib3==2.2.0 + # via + # requests + # twine +zipp==3.17.0 + # via importlib-metadata diff --git a/setup.cfg b/setup.cfg index 109742d..61f9b16 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,7 @@ [flake8] -max-line-length = 100 +max-line-length = 88 +exclude = + venv [tool:pytest] log_cli = 1 diff --git a/setup.py b/setup.py index a025cf0..20408a9 100644 --- a/setup.py +++ b/setup.py @@ -2,31 +2,33 @@ from setuptools import setup, find_packages -with open('README.rst') as fp: +with open("README.rst") as fp: long_description = fp.read() -long_description = long_description.replace('.. doctest::', '.. code-block:: python') -long_description = re.sub(r'(\.\. autofunction:: .*?$)', r':code:`\1`', long_description) +long_description = long_description.replace(".. doctest::", ".. code-block:: python") +long_description = re.sub( + r"(\.\. autofunction:: .*?$)", r":code:`\1`", long_description +) tests_require = [ - 'flake8', - 'pytest', - 'pytest-cov', + "flake8", + "pytest", + "pytest-cov", ] setup( - name='localscope', - version='0.1.3', - author='Till Hoffmann', + name="localscope", + version="0.1.3", + author="Till Hoffmann", packages=find_packages(), - url='https://github.com/tillahoffmann/localscope', + url="https://github.com/tillahoffmann/localscope", long_description_content_type="text/x-rst", long_description=long_description, tests_require=tests_require, extras_require={ - 'docs': [ - 'sphinx', + "docs": [ + "sphinx", ], - 'tests': tests_require, - } + "tests": tests_require, + }, ) diff --git a/tests/test_localscope.py b/tests/test_localscope.py index 701eaf3..41bc69c 100644 --- a/tests/test_localscope.py +++ b/tests/test_localscope.py @@ -11,11 +11,13 @@ def test_vanilla_function(): @localscope def add(a, b): return a + b + assert add(1, 2) == 3 def test_missing_global(): with pytest.raises(NameError): + @localscope def func(): return never_ever_declared # noqa: F821 @@ -23,6 +25,7 @@ def func(): def test_forbidden_global(): with pytest.raises(ValueError): + @localscope def return_forbidden_global(): return forbidden_global @@ -32,11 +35,12 @@ def test_builtin(): @localscope def transpose(a, b): return list(zip(a, b)) + assert transpose([1, 2], [3, 4]) == [(1, 3), (2, 4)] def test_allowed(): - @localscope(allowed=['allowed_global']) + @localscope(allowed=["allowed_global"]) def return_allowed_global(): return allowed_global @@ -50,7 +54,9 @@ def wrapper(): @localscope def return_forbidden_closure(): return forbidden_closure + return return_forbidden_closure() + with pytest.raises(ValueError): wrapper() @@ -62,13 +68,16 @@ def wrapper(): @localscope(allow_closure=True) def return_forbidden_closure(): return forbidden_closure + return return_forbidden_closure() + assert wrapper() == forbidden_closure def test_allow_custom_predicate(): decorator = localscope(predicate=lambda x: isinstance(x, int)) with pytest.raises(ValueError): + @decorator def return_forbidden_global(): return forbidden_global @@ -76,11 +85,13 @@ def return_forbidden_global(): @decorator def return_integer_global(): return integer_global + assert return_integer_global() == integer_global def test_comprehension(): with pytest.raises(ValueError): + @localscope def evaluate_mse(xs, ys): # missing argument integer_global return sum(((x - y) / integer_global) ** 2 for x, y in zip(xs, ys)) @@ -88,17 +99,19 @@ def evaluate_mse(xs, ys): # missing argument integer_global def test_recursive(): with pytest.raises(ValueError): + @localscope def wrapper(): def return_forbidden_global(): return forbidden_global + return return_forbidden_global() def test_recursive_local_closure(): @localscope def wrapper(): - a = 'hello world' + a = "hello world" def child(): return a @@ -122,6 +135,7 @@ def doit(): x = 1 with pytest.raises(ValueError): + @localscope.mfc def breakit(): x + 1 @@ -131,6 +145,7 @@ def test_comprehension_with_argument(): @localscope def f(n): return [n for i in range(n)] + assert f(2) == [2, 2] @@ -139,6 +154,7 @@ def test_comprehension_with_closure(): def f(): n = 3 return [n for i in range(n)] + assert f() == [3, 3, 3] @@ -146,6 +162,7 @@ def test_argument(): @localscope def add(a): return a + 1 + assert add(3) == 4 @@ -154,6 +171,7 @@ def test_argument_with_closure(): def add(a): return a + 1 lambda: a + assert add(3) == 4 @@ -162,4 +180,5 @@ def test_local_deref(): def identity(x): return x lambda: x + assert identity(42) == 42