pax_global_header00006660000000000000000000000064145023224650014515gustar00rootroot0000000000000052 comment=9d3f894a53da084961aeb7e298c38f7d889b1cfc apipkg-3.0.2/000077500000000000000000000000001450232246500127725ustar00rootroot00000000000000apipkg-3.0.2/.flake8000066400000000000000000000000631450232246500141440ustar00rootroot00000000000000[flake8] max-line-length = 88 extend-ignore = E203 apipkg-3.0.2/.github/000077500000000000000000000000001450232246500143325ustar00rootroot00000000000000apipkg-3.0.2/.github/workflows/000077500000000000000000000000001450232246500163675ustar00rootroot00000000000000apipkg-3.0.2/.github/workflows/pre-commit.yml000066400000000000000000000004171450232246500211700ustar00rootroot00000000000000name: pre-commit on: pull_request: push: branches: [main] jobs: pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: 3.x - uses: pre-commit/action@v2.0.3 apipkg-3.0.2/.github/workflows/pythonapp.yml000066400000000000000000000044551450232246500211440ustar00rootroot00000000000000name: "python tests+artifacts+release" on: pull_request: push: branches: - main tags: - "v*" release: types: [published] jobs: dist: runs-on: ubuntu-latest name: Python sdist/wheel steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip pip install --upgrade wheel setuptools build - name: Build package run: python -m build -o dist/ - uses: actions/upload-artifact@v3 with: name: dist path: dist test: runs-on: ubuntu-latest needs: [dist] strategy: matrix: # todo: extract from source python-version: [3.7, 3.8, 3.9, "3.10", "3.11-dev"] install-from: ["dist/*.whl"] include: - python-version: "3.10" install-from: "-e ." - python-version: "3.10" install-from: "." - python-version: "3.10" install-from: "dist/*.tar.gz" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -U setuptools setuptools_scm pip install pytest - uses: actions/download-artifact@v3 with: name: dist path: dist - name: install editable run: pip install ${{ matrix.install-from }} - name: pytest run: pytest dist_check: runs-on: ubuntu-latest needs: [dist] steps: - uses: actions/setup-python@v4 with: python-version: "3.10" - uses: actions/download-artifact@v3 with: name: dist path: dist - run: pipx run twine check --strict dist/* dist_upload: runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') needs: [dist_check, test] steps: - uses: actions/download-artifact@v3 with: name: dist path: dist - name: Publish package to PyPI uses: pypa/gh-action-pypi-publish@master with: user: __token__ password: ${{ secrets.pypi_token }} apipkg-3.0.2/.gitignore000066400000000000000000000001201450232246500147530ustar00rootroot00000000000000src/apipkg/_version.py __pycache__ *.egg-info .pytest_cache/ .eggs/ .tox/ dist/ apipkg-3.0.2/.pre-commit-config.yaml000066400000000000000000000020151450232246500172510ustar00rootroot00000000000000repos: - repo: https://github.com/psf/black rev: 23.9.1 hooks: - id: black args: [--safe, --quiet] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: fix-encoding-pragma args: [--remove] - id: check-yaml - id: debug-statements - repo: https://github.com/pycqa/flake8 rev: 6.1.0 hooks: - id: flake8 additional_dependencies: - flake8-typing-imports==1.14.0 - repo: https://github.com/asottile/reorder-python-imports rev: v3.11.0 hooks: - id: reorder-python-imports args: ['--application-directories=.:src'] - repo: https://github.com/asottile/pyupgrade rev: v3.11.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/tox-dev/pyproject-fmt rev: "1.1.0" hooks: - id: pyproject-fmt - repo: https://github.com/pre-commit/mirrors-mypy rev: 'v1.5.1' hooks: - id: mypy apipkg-3.0.2/CHANGELOG000066400000000000000000000064621450232246500142140ustar00rootroot000000000000003.0.1 ------ * restore tox.ini to support tox --current-env based packaging 3.0.0 ----- * add support for python 3.11 and drop dead pythons (thanks hukgo) * migrate to hatch * split up __init__.py * add some type annotations 2.1.1 ----- * drop the python 3.4 support marker, 2.1.0 broke it 2.1.0 will be yanked after release 2.1.0 ---------------------------------------- - fix race condition for import of modules using apipkg.initpkg in Python 3.3+ by updating existing modules in-place rather than replacing in sys.modules with an apipkg.ApiModule instances. This race condition exists for import statements (and __import__) in Python 3.3+ where sys.modules is checked before obtaining an import lock, and for importlib.import_module in Python 3.11+ for the same reason. 2.0.1 ---------------------------------------- - fix race conditions for attribute creation 2.0.0 ---------------------------------------- - also transfer __spec__ attribute - make py.test hack more specific to avoid hiding real errors - switch from Travis CI to GitHub Actions - modernize package build - reformat code with black 1.5 ---------------------------------------- - switch to setuptools_scm - move to github - fix up python compat matrix - avoid dict iteration (fixes issue on python3) - preserve __package__ - ths gets us better pep 302 compliance 1.4 ---------------------------------------- - revert the automated version gathering 1.3 ---------------------------------------- - fix issue2 - adapt tests on Jython - handle jython __pkgpath__ missabstraction when running python from jar files - alias modules pointing to unimportable modules will return None for all their attributes instead of raising ImportError. This addresses python3.4 where any call to getframeinfo() can choke on sys.modules contents if pytest is not installed (because py.test.* imports it). - introduce apipkg.distribution_version(name) as helper to obtain the current version number of a package from install metadata its used by default with the package name - add an eagerloading option and eagerload automatically if bpython is used (workaround for their monkeypatching) 1.2 ---------------------------------------- - Allow to import from Aliasmodules (thanks Ralf Schmitt) - avoid loading __doc__ early, so ApiModule is now fully lazy 1.1 ---------------------------------------- - copy __doc__ and introduce a new argument to initpkg() (thanks Ralf Schmitt) - don't use a "0" default for __version__ 1.0 ---------------------------------------- - make apipkg memorize the absolute path where a package starts importing so that subsequent chdir + imports won't break. - allow to alias modules - allow to use dotted names in alias specifications (thanks Ralf Schmitt). 1.0.0b6 ---------------------------------------- - fix recursive import issue resulting in a superflous KeyError - default to __version__ '0' and not set __loader__ or __path__ at all if it doesn't exist on the underlying init module 1.0.0b5 ---------------------------------------- - fixed MANIFEST.in - also transfer __loader__ attribute (thanks Ralf Schmitt) - compat fix for BPython 1.0.0b3 (compared to 1.0.0b2) ------------------------------------ - added special __onfirstaccess__ attribute whose value will be called on the first attribute access of an apimodule. apipkg-3.0.2/LICENSE000066400000000000000000000020361450232246500140000ustar00rootroot00000000000000 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. apipkg-3.0.2/MANIFEST.in000066400000000000000000000001131450232246500145230ustar00rootroot00000000000000include tox.ini include setup.cfg include CHANGELOG include test_apipkg.py apipkg-3.0.2/README.rst000066400000000000000000000056531450232246500144720ustar00rootroot00000000000000Welcome to apipkg ! ------------------- With apipkg you can control the exported namespace of a Python package and greatly reduce the number of imports for your users. It is a `small pure Python module`_ that works on CPython 3.7+, Jython and PyPy. It cooperates well with Python's ``help()`` system, custom importers (PEP302) and common command-line completion tools. Usage is very simple: you can require 'apipkg' as a dependency or you can copy paste the ~200 lines of code into your project. Tutorial example ------------------- Here is a simple ``mypkg`` package that specifies one namespace and exports two objects imported from different modules: .. code-block:: python # mypkg/__init__.py import apipkg apipkg.initpkg(__name__, { 'path': { 'Class1': "_mypkg.somemodule:Class1", 'clsattr': "_mypkg.othermodule:Class2.attr", } } The package is initialized with a dictionary as namespace. You need to create a ``_mypkg`` package with a ``somemodule.py`` and ``othermodule.py`` containing the respective classes. The ``_mypkg`` is not special - it's a completely regular Python package. Namespace dictionaries contain ``name: value`` mappings where the value may be another namespace dictionary or a string specifying an import location. On accessing an namespace attribute an import will be performed: .. code-block:: pycon >>> import mypkg >>> mypkg.path >>> mypkg.path.Class1 # '_mypkg.somemodule' gets imported now >>> mypkg.path.clsattr # '_mypkg.othermodule' gets imported now 4 # the value of _mypkg.othermodule.Class2.attr The ``mypkg.path`` namespace and its two entries are loaded when they are accessed. This means: * lazy loading - only what is actually needed is ever loaded * only the root "mypkg" ever needs to be imported to get access to the complete functionality * the underlying modules are also accessible, for example: .. code-block:: python from mypkg.sub import Class1 Including apipkg in your package -------------------------------------- If you don't want to add an ``apipkg`` dependency to your package you can copy the `apipkg.py`_ file somewhere to your own package, for example ``_mypkg/apipkg.py`` in the above example. You then import the ``initpkg`` function from that new place and are good to go. .. _`small pure Python module`: .. _`apipkg.py`: https://github.com/pytest-dev/apipkg/blob/master/src/apipkg/__init__.py Feedback? ----------------------- If you have questions you are welcome to * join the **#pytest** channel on irc.libera.chat_ (using an IRC client, via webchat_, or via Matrix_). * create an issue on the bugtracker_ .. _irc.libera.chat: ircs://irc.libera.chat:6697/#pytest .. _webchat: https://web.libera.chat/#pytest .. _matrix: https://matrix.to/#/%23pytest:libera.chat .. _bugtracker: https://github.com/pytest-dev/apipkg/issues apipkg-3.0.2/conftest.py000066400000000000000000000005471450232246500151770ustar00rootroot00000000000000import pathlib import apipkg LOCAL_APIPKG = pathlib.Path(__file__).parent.joinpath("src/apipkg/__init__.py") INSTALL_TYPE = "editable" if apipkg.__file__ == LOCAL_APIPKG else "full" def pytest_report_header(startdir): return "apipkg {install_type} install version={version}".format( install_type=INSTALL_TYPE, version=apipkg.__version__ ) apipkg-3.0.2/example/000077500000000000000000000000001450232246500144255ustar00rootroot00000000000000apipkg-3.0.2/example/_mypkg/000077500000000000000000000000001450232246500157135ustar00rootroot00000000000000apipkg-3.0.2/example/_mypkg/__init__.py000066400000000000000000000000001450232246500200120ustar00rootroot00000000000000apipkg-3.0.2/example/_mypkg/othermodule.py000066400000000000000000000000331450232246500206100ustar00rootroot00000000000000class OtherClass: pass apipkg-3.0.2/example/_mypkg/somemodule.py000066400000000000000000000001241450232246500204330ustar00rootroot00000000000000from _mypkg.othermodule import OtherClass # NOQA: F401 class SomeClass: pass apipkg-3.0.2/example/mypkg/000077500000000000000000000000001450232246500155545ustar00rootroot00000000000000apipkg-3.0.2/example/mypkg/__init__.py000066400000000000000000000003321450232246500176630ustar00rootroot00000000000000# mypkg/__init__.py import apipkg apipkg.initpkg( __name__, { "SomeClass": "_mypkg.somemodule:SomeClass", "sub": { "OtherClass": "_mypkg.somemodule:OtherClass", }, }, ) apipkg-3.0.2/example/mypkg/othermodule.py000066400000000000000000000000331450232246500204510ustar00rootroot00000000000000class OtherClass: pass apipkg-3.0.2/example/mypkg/somemodule.py000066400000000000000000000001251450232246500202750ustar00rootroot00000000000000from __mypkg.othermodule import OtherClass # NOQA: F401 class SomeClass: pass apipkg-3.0.2/pyproject.toml000066400000000000000000000032271450232246500157120ustar00rootroot00000000000000[build-system] build-backend = "hatchling.build" requires = [ "hatch-vcs", "hatchling>=0.24", ] [project] name = "apipkg" description = "apipkg: namespace control and lazy-import mechanism" readme = "README.rst" license = "MIT" maintainers = [ { name = "Ronny Pfannschmidt", email = "opensource+apipkg@ronnypfannschmidt.de"} ] authors = [ { name = "holger krekel" }, ] requires-python = ">=3.7" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Libraries", ] dynamic = [ "version", ] [project.urls] Homepage = "https://github.com/pytest-dev/apipkg" [tool.hatch.version] source = "vcs" [tool.hatch.build.targets.sdist] include = [ "/src", ] [tool.hatch.build.hooks.vcs] version-file = "src/apipkg/_version.py" [tool.hatch.envs.test] dependencies = [ "coverage[toml]", "pytest", "pytest-cov", ] [[tool.hatch.envs.test.matrix]] python = ["3.7", "3.8", "3.9", "3.10", "3.11"] [tool.hatch.envs.test.scripts] run-coverage = "pytest --cov-config=pyproject.toml --cov=pkg --cov=tests {args}" run = "run-coverage --no-cov {args}" [mypy] python_version = "3.7" apipkg-3.0.2/src/000077500000000000000000000000001450232246500135615ustar00rootroot00000000000000apipkg-3.0.2/src/apipkg/000077500000000000000000000000001450232246500150345ustar00rootroot00000000000000apipkg-3.0.2/src/apipkg/__init__.py000066400000000000000000000021431450232246500171450ustar00rootroot00000000000000""" apipkg: control the exported namespace of a Python package. see https://pypi.python.org/pypi/apipkg (c) holger krekel, 2009 - MIT license """ from __future__ import annotations __all__ = ["initpkg", "ApiModule", "AliasModule", "__version__", "distribution_version"] import sys from typing import Any from ._alias_module import AliasModule from ._importing import distribution_version as distribution_version from ._module import _initpkg from ._module import ApiModule from ._version import version as __version__ def initpkg( pkgname: str, exportdefs: dict[str, Any], attr: dict[str, object] | None = None, eager: bool = False, ) -> ApiModule: """initialize given package from the export definitions.""" attr = attr or {} mod = sys.modules.get(pkgname) mod = _initpkg(mod, pkgname, exportdefs, attr=attr) # eagerload in bypthon to avoid their monkeypatching breaking packages if "bpython" in sys.modules or eager: for module in list(sys.modules.values()): if isinstance(module, ApiModule): getattr(module, "__dict__") return mod apipkg-3.0.2/src/apipkg/_alias_module.py000066400000000000000000000022411450232246500202020ustar00rootroot00000000000000from __future__ import annotations from types import ModuleType from ._importing import importobj def AliasModule(modname: str, modpath: str, attrname: str | None = None) -> ModuleType: cached_obj: object | None = None def getmod() -> object: nonlocal cached_obj if cached_obj is None: cached_obj = importobj(modpath, attrname) return cached_obj x = modpath + ("." + attrname if attrname else "") repr_result = f"" class AliasModule(ModuleType): def __repr__(self) -> str: return repr_result def __getattribute__(self, name: str) -> object: try: return getattr(getmod(), name) except ImportError: if modpath == "pytest" and attrname is None: # hack for pylibs py.test return None else: raise def __setattr__(self, name: str, value: object) -> None: setattr(getmod(), name, value) def __delattr__(self, name: str) -> None: delattr(getmod(), name) return AliasModule(str(modname)) apipkg-3.0.2/src/apipkg/_importing.py000066400000000000000000000020531450232246500175550ustar00rootroot00000000000000from __future__ import annotations import os import sys def _py_abspath(path: str) -> str: """ special version of abspath that will leave paths from jython jars alone """ if path.startswith("__pyclasspath__"): return path else: return os.path.abspath(path) def distribution_version(name: str) -> str | None: """try to get the version of the named distribution, returns None on failure""" if sys.version_info >= (3, 8): from importlib.metadata import PackageNotFoundError, version else: from importlib_metadata import PackageNotFoundError, version try: return version(name) except PackageNotFoundError: return None def importobj(modpath: str, attrname: str | None) -> object: """imports a module, then resolves the attrname on it""" module = __import__(modpath, None, None, ["__doc__"]) if not attrname: return module retval = module names = attrname.split(".") for x in names: retval = getattr(retval, x) return retval apipkg-3.0.2/src/apipkg/_module.py000066400000000000000000000153501450232246500170360ustar00rootroot00000000000000from __future__ import annotations import sys import threading from types import ModuleType from typing import Any from typing import Callable from typing import cast from typing import Iterable from ._alias_module import AliasModule from ._importing import _py_abspath from ._importing import importobj from ._syncronized import _synchronized class ApiModule(ModuleType): """the magical lazy-loading module standing""" def __docget(self) -> str | None: try: return self.__doc except AttributeError: if "__doc__" in self.__map__: return cast(str, self.__makeattr("__doc__")) else: return None def __docset(self, value: str) -> None: self.__doc = value __doc__ = property(__docget, __docset) # type: ignore __map__: dict[str, tuple[str, str]] def __init__( self, name: str, importspec: dict[str, Any], implprefix: str | None = None, attr: dict[str, Any] | None = None, ) -> None: super().__init__(name) self.__name__ = name self.__all__ = [x for x in importspec if x != "__onfirstaccess__"] self.__map__ = {} self.__implprefix__ = implprefix or name if attr: for name, val in attr.items(): setattr(self, name, val) for name, importspec in importspec.items(): if isinstance(importspec, dict): subname = f"{self.__name__}.{name}" apimod = ApiModule(subname, importspec, implprefix) sys.modules[subname] = apimod setattr(self, name, apimod) else: parts = importspec.split(":") modpath = parts.pop(0) attrname = parts and parts[0] or "" if modpath[0] == ".": modpath = implprefix + modpath if not attrname: subname = f"{self.__name__}.{name}" apimod = AliasModule(subname, modpath) sys.modules[subname] = apimod if "." not in name: setattr(self, name, apimod) else: self.__map__[name] = (modpath, attrname) def __repr__(self): repr_list = [f"") return "".join(repr_list) @_synchronized def __makeattr(self, name, isgetattr=False): """lazily compute value for name or raise AttributeError if unknown.""" target = None if "__onfirstaccess__" in self.__map__: target = self.__map__.pop("__onfirstaccess__") fn = cast(Callable[[], None], importobj(*target)) fn() try: modpath, attrname = self.__map__[name] except KeyError: # __getattr__ is called when the attribute does not exist, but it may have # been set by the onfirstaccess call above. Infinite recursion is not # possible as __onfirstaccess__ is removed before the call (unless the call # adds __onfirstaccess__ to __map__ explicitly, which is not our problem) if target is not None and name != "__onfirstaccess__": return getattr(self, name) # Attribute may also have been set during a concurrent call to __getattr__ # which executed after this call was already waiting on the lock. Check # for a recently set attribute while avoiding infinite recursion: # * Don't call __getattribute__ if __makeattr was called from a data # descriptor such as the __doc__ or __dict__ properties, since data # descriptors are called as part of object.__getattribute__ # * Only call __getattribute__ if there is a possibility something has set # the attribute we're looking for since __getattr__ was called if threading is not None and isgetattr: return super().__getattribute__(name) raise AttributeError(name) else: result = importobj(modpath, attrname) setattr(self, name, result) # in a recursive-import situation a double-del can happen self.__map__.pop(name, None) return result def __getattr__(self, name): return self.__makeattr(name, isgetattr=True) def __dir__(self) -> Iterable[str]: yield from super().__dir__() yield from self.__map__ @property def __dict__(self) -> dict[str, Any]: # type: ignore # force all the content of the module # to be loaded when __dict__ is read dictdescr = ModuleType.__dict__["__dict__"] # type: ignore ns: dict[str, Any] = dictdescr.__get__(self) if ns is not None: hasattr(self, "some") for name in self.__all__: try: self.__makeattr(name) except AttributeError: pass return ns _PRESERVED_MODULE_ATTRS = { "__file__", "__version__", "__loader__", "__path__", "__package__", "__doc__", "__spec__", "__dict__", } def _initpkg(mod: ModuleType | None, pkgname, exportdefs, attr=None) -> ApiModule: """Helper for initpkg. Python 3.3+ uses finer grained locking for imports, and checks sys.modules before acquiring the lock to avoid the overhead of the fine-grained locking. This introduces a race condition when a module is imported by multiple threads concurrently - some threads will see the initial module and some the replacement ApiModule. We avoid this by updating the existing module in-place. """ if mod is None: d = {"__file__": None, "__spec__": None} d.update(attr) mod = ApiModule(pkgname, exportdefs, implprefix=pkgname, attr=d) sys.modules[pkgname] = mod return mod else: f = getattr(mod, "__file__", None) if f: f = _py_abspath(f) mod.__file__ = f if hasattr(mod, "__path__"): mod.__path__ = [_py_abspath(p) for p in mod.__path__] if "__doc__" in exportdefs and hasattr(mod, "__doc__"): del mod.__doc__ for name in dir(mod): if name not in _PRESERVED_MODULE_ATTRS: delattr(mod, name) # Updating class of existing module as per importlib.util.LazyLoader mod.__class__ = ApiModule apimod = cast(ApiModule, mod) ApiModule.__init__(apimod, pkgname, exportdefs, implprefix=pkgname, attr=attr) return apimod apipkg-3.0.2/src/apipkg/_syncronized.py000066400000000000000000000007441450232246500201210ustar00rootroot00000000000000from __future__ import annotations import functools import threading def _synchronized(wrapped_function): """Decorator to synchronise __getattr__ calls.""" # Lock shared between all instances of ApiModule to avoid possible deadlocks lock = threading.RLock() @functools.wraps(wrapped_function) def synchronized_wrapper_function(*args, **kwargs): with lock: return wrapped_function(*args, **kwargs) return synchronized_wrapper_function apipkg-3.0.2/src/apipkg/py.typed000066400000000000000000000000001450232246500165210ustar00rootroot00000000000000apipkg-3.0.2/test_apipkg.py000066400000000000000000000572061450232246500156700ustar00rootroot00000000000000import os.path import subprocess import sys import textwrap import threading import types import pytest import apipkg._alias_module # # test support for importing modules # ModuleType = types.ModuleType class TestRealModule: @pytest.fixture(scope="class", autouse=True) def make_modules(cls, tmpdir_factory): cls.tmpdir = tmpdir_factory.mktemp("test_apipkg") sys.path = [str(cls.tmpdir)] + sys.path pkgdir = cls.tmpdir.ensure("realtest", dir=1) tfile = pkgdir.join("__init__.py") tfile.write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, { 'x': { 'module': { '__doc__': '_xyz.testmodule:__doc__', 'mytest0': '_xyz.testmodule:mytest0', 'mytest1': '_xyz.testmodule:mytest1', 'MyTest': '_xyz.testmodule:MyTest', } } } ) """ ) ) ipkgdir = cls.tmpdir.ensure("_xyz", dir=1) tfile = ipkgdir.join("testmodule.py") ipkgdir.ensure("__init__.py") tfile.write( textwrap.dedent( """ 'test module' from _xyz.othermodule import MyTest #__all__ = ['mytest0', 'mytest1', 'MyTest'] def mytest0(): pass def mytest1(): pass """ ) ) ipkgdir.join("othermodule.py").write("class MyTest: pass") def setup_method(self, *args): # Unload the test modules before each test. module_names = "realtest", "realtest.x", "realtest.x.module" for modname in module_names: sys.modules.pop(modname, None) def test_realmodule(self): import realtest.x # type: ignore assert "realtest.x.module" in sys.modules assert getattr(realtest.x.module, "mytest0") def test_realmodule_repr(self): import realtest.x # type: ignore assert "" == repr(realtest.x) def test_realmodule_from(self): from realtest.x import module # type: ignore assert getattr(module, "mytest1") def test_realmodule__all__(self): import realtest.x.module # type: ignore assert realtest.x.__all__ == ["module"] assert len(realtest.x.module.__all__) == 4 def test_realmodule_dict_import(self): "Test verifying that accessing the __dict__ invokes the import" import realtest.x.module moddict = realtest.x.module.__dict__ assert "mytest0" in moddict assert "mytest1" in moddict assert "MyTest" in moddict def test_realmodule___doc__(self): """test whether the __doc__ attribute is set properly from initpkg""" import realtest.x.module print(realtest.x.module.__map__) assert realtest.x.module.__doc__ == "test module" class TestScenarios: def test_relative_import(self, monkeypatch, tmpdir): pkgdir = tmpdir.mkdir("mymodule") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, exportdefs={ '__doc__': '.submod:maindoc', 'x': '.submod:x', 'y': { 'z': '.submod:x' }, }) """ ) ) pkgdir.join("submod.py").write("x=3\nmaindoc='hello'") monkeypatch.syspath_prepend(tmpdir) import mymodule assert isinstance(mymodule, apipkg.ApiModule) assert mymodule.x == 3 assert mymodule.__doc__ == "hello" assert mymodule.y.z == 3 def test_recursive_import(self, monkeypatch, tmpdir): pkgdir = tmpdir.mkdir("recmodule") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, exportdefs={ 'some': '.submod:someclass', }) """ ) ) pkgdir.join("submod.py").write( textwrap.dedent( """ import recmodule class someclass: pass print(recmodule.__dict__) """ ) ) monkeypatch.syspath_prepend(tmpdir) import recmodule assert isinstance(recmodule, apipkg.ApiModule) assert recmodule.some.__name__ == "someclass" def test_module_alias_import(self, monkeypatch, tmpdir): pkgdir = tmpdir.mkdir("aliasimport") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, exportdefs={ 'some': 'os.path', }) """ ) ) monkeypatch.syspath_prepend(tmpdir) import aliasimport for k, v in os.path.__dict__.items(): assert getattr(aliasimport.some, k) == v def test_from_module_alias_import(self, monkeypatch, tmpdir): pkgdir = tmpdir.mkdir("fromaliasimport") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, exportdefs={ 'some': 'os.path', }) """ ) ) monkeypatch.syspath_prepend(tmpdir) from fromaliasimport.some import join assert join is os.path.join def xtest_nested_absolute_imports(): apipkg.ApiModule( "email", { "message2": { "Message": "email.message:Message", }, }, ) # nesting is supposed to put nested items into sys.modules assert "email.message2" in sys.modules # alternate ideas for specifying package + preliminary code # def test_parsenamespace(): spec = """ path.local __.path.local::LocalPath path.svnwc __.path.svnwc::WCCommandPath test.raises __.test.outcome::raises """ d = parsenamespace(spec) print(d) assert d == { "test": {"raises": "__.test.outcome::raises"}, "path": { "svnwc": "__.path.svnwc::WCCommandPath", "local": "__.path.local::LocalPath", }, } def xtest_parsenamespace_errors(): pytest.raises( ValueError, """ parsenamespace('path.local xyz') """, ) pytest.raises( ValueError, """ parsenamespace('x y z') """, ) def parsenamespace(spec): ns = {} for line in spec.split("\n"): line = line.strip() if not line or line[0] == "#": continue parts = [x.strip() for x in line.split()] if len(parts) != 2: raise ValueError(f"Wrong format: {line!r}") apiname, spec = parts if not spec.startswith("__"): raise ValueError(f"{spec!r} does not start with __") apinames = apiname.split(".") cur = ns for name in apinames[:-1]: cur.setdefault(name, {}) cur = cur[name] cur[apinames[-1]] = spec return ns def test_initpkg_updates_sysmodules(monkeypatch): mod = ModuleType("hello") monkeypatch.setitem(sys.modules, "hello", mod) apipkg.initpkg("hello", {"x": "os.path:abspath"}) newmod = sys.modules["hello"] assert newmod is mod assert newmod.x == os.path.abspath def test_initpkg_transfers_attrs(monkeypatch): mod = ModuleType("hello") mod.__version__ = 10 mod.__file__ = "hello.py" mod.__loader__ = "loader" mod.__package__ = "package" mod.__doc__ = "this is the documentation" mod.goodbye = "goodbye" monkeypatch.setitem(sys.modules, "hello", mod) apipkg.initpkg("hello", {}) newmod = sys.modules["hello"] assert newmod is mod assert newmod.__file__ == os.path.abspath(mod.__file__) assert newmod.__version__ == mod.__version__ assert newmod.__loader__ == mod.__loader__ assert newmod.__package__ == mod.__package__ assert newmod.__doc__ == mod.__doc__ assert not hasattr(newmod, "goodbye") def test_initpkg_nodoc(monkeypatch): mod = ModuleType("hello") mod.__file__ = "hello.py" monkeypatch.setitem(sys.modules, "hello", mod) apipkg.initpkg("hello", {}) newmod = sys.modules["hello"] assert not newmod.__doc__ def test_initpkg_overwrite_doc(monkeypatch): hello = ModuleType("hello") hello.__doc__ = "this is the documentation" monkeypatch.setitem(sys.modules, "hello", hello) apipkg.initpkg("hello", {"__doc__": "sys:__doc__"}) newhello = sys.modules["hello"] assert newhello is hello assert newhello.__doc__ == sys.__doc__ def test_initpkg_not_transfers_not_existing_attrs(monkeypatch): mod = ModuleType("hello") mod.__file__ = "hello.py" assert not hasattr(mod, "__path__") assert not hasattr(mod, "__package__") or mod.__package__ is None monkeypatch.setitem(sys.modules, "hello", mod) apipkg.initpkg("hello", {}) newmod = sys.modules["hello"] assert newmod is mod assert newmod.__file__ == os.path.abspath(mod.__file__) assert not hasattr(newmod, "__path__") assert not hasattr(newmod, "__package__") or mod.__package__ is None def test_initpkg_not_changing_jython_paths(monkeypatch): mod = ModuleType("hello") mod.__file__ = "__pyclasspath__/test.py" mod.__path__ = ["__pyclasspath__/fun", "ichange"] monkeypatch.setitem(sys.modules, "hello", mod) apipkg.initpkg("hello", {}) newmod = sys.modules["hello"] assert newmod is mod assert newmod.__file__.startswith("__pyclasspath__") unchanged, changed = newmod.__path__ assert changed != "ichange" assert unchanged.startswith("__pyclasspath__") def test_initpkg_defaults(monkeypatch): mod = ModuleType("hello") monkeypatch.setitem(sys.modules, "hello", mod) apipkg.initpkg("hello", {}) newmod = sys.modules["hello"] assert newmod.__file__ is None assert not hasattr(newmod, "__version__") def test_name_attribute(): api = apipkg.ApiModule( "name_test", { "subpkg": {}, }, ) assert api.__name__ == "name_test" assert api.subpkg.__name__ == "name_test.subpkg" def test_error_loading_one_element(monkeypatch, tmpdir): pkgdir = tmpdir.mkdir("errorloading1") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, exportdefs={ 'x': '.notexists:x', 'y': '.submod:y' }, ) """ ) ) pkgdir.join("submod.py").write("y=0") monkeypatch.syspath_prepend(tmpdir) import errorloading1 assert isinstance(errorloading1, apipkg.ApiModule) assert errorloading1.y == 0 with pytest.raises(ImportError): errorloading1.x with pytest.raises(ImportError): errorloading1.x def test_onfirstaccess(tmpdir, monkeypatch): pkgdir = tmpdir.mkdir("firstaccess") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, exportdefs={ '__onfirstaccess__': '.submod:init', 'l': '.submod:l', }, ) """ ) ) pkgdir.join("submod.py").write( textwrap.dedent( """ l = [] def init(): l.append(1) """ ) ) monkeypatch.syspath_prepend(tmpdir) import firstaccess assert isinstance(firstaccess, apipkg.ApiModule) assert len(firstaccess.l) == 1 assert len(firstaccess.l) == 1 assert "__onfirstaccess__" not in firstaccess.__all__ @pytest.mark.parametrize("mode", ["attr", "dict", "onfirst"]) def test_onfirstaccess_setsnewattr(tmpdir, monkeypatch, mode): pkgname = "mode_" + mode pkgdir = tmpdir.mkdir(pkgname) pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, exportdefs={ '__onfirstaccess__': '.submod:init', }, ) """ ) ) pkgdir.join("submod.py").write( textwrap.dedent( """ def init(): import %s as pkg pkg.newattr = 42 """ % pkgname ) ) monkeypatch.syspath_prepend(tmpdir) mod = __import__(pkgname) assert isinstance(mod, apipkg.ApiModule) if mode == "attr": assert mod.newattr == 42 elif mode == "dict": print(list(mod.__dict__.keys())) assert "newattr" in mod.__dict__ elif mode == "onfirst": assert not hasattr(mod, "__onfirstaccess__") assert not hasattr(mod, "__onfirstaccess__") assert "__onfirstaccess__" not in vars(mod) @pytest.mark.skipif("threading" not in sys.modules, reason="requires thread support") def test_onfirstaccess_race(tmpdir, monkeypatch): pkgdir = tmpdir.mkdir("firstaccessrace") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, exportdefs={ '__onfirstaccess__': '.submod:init', 'l': '.submod:l', }, ) """ ) ) pkgdir.join("submod.py").write( textwrap.dedent( """ import time l = [] def init(): time.sleep(0.1) l.append(1) """ ) ) monkeypatch.syspath_prepend(tmpdir) import firstaccessrace assert isinstance(firstaccessrace, apipkg.ApiModule) class TestThread(threading.Thread): def __init__(self, event_start): super().__init__() self.event_start = event_start self.lenl = None def run(self): self.event_start.wait() self.lenl = len(firstaccessrace.l) event_start = threading.Event() threads = [TestThread(event_start) for _ in range(8)] for thread in threads: thread.start() event_start.set() for thread in threads: thread.join() assert len(firstaccessrace.l) == 1 for thread in threads: assert thread.lenl == 1 assert "__onfirstaccess__" not in firstaccessrace.__all__ @pytest.mark.skipif("threading" not in sys.modules, reason="requires thread support") def test_attribute_race(tmpdir, monkeypatch): pkgdir = tmpdir.mkdir("attributerace") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, exportdefs={ 'attr': '.submod:attr', }, ) """ ) ) pkgdir.join("submod.py").write( textwrap.dedent( """ import time time.sleep(0.1) attr = 42 """ ) ) monkeypatch.syspath_prepend(tmpdir) import attributerace assert isinstance(attributerace, apipkg.ApiModule) class TestThread(threading.Thread): def __init__(self, event_start): super().__init__() self.event_start = event_start self.attr = None def run(self): self.event_start.wait() self.attr = attributerace.attr event_start = threading.Event() threads = [TestThread(event_start) for _ in range(8)] for thread in threads: thread.start() event_start.set() for thread in threads: thread.join() assert attributerace.attr == 42 for thread in threads: assert thread.attr == 42 @pytest.mark.skipif("threading" not in sys.modules, reason="requires thread support") def test_import_race(tmpdir, monkeypatch): pkgdir = tmpdir.mkdir("importrace") pkgdir.join("__init__.py").write( textwrap.dedent( """ import time time.sleep(0.1) import apipkg apipkg.initpkg(__name__, exportdefs={ 'attr': '.submod:attr', }, ) """ ) ) pkgdir.join("submod.py").write( textwrap.dedent( """ attr = 43 """ ) ) monkeypatch.syspath_prepend(tmpdir) class TestThread(threading.Thread): def __init__(self): super().__init__() self.importrace = None def run(self): import importrace self.importrace = importrace threads = [TestThread() for _ in range(8)] for thread in threads: thread.start() for thread in threads: thread.join() for thread in threads: assert isinstance(thread.importrace, apipkg.ApiModule) assert thread.importrace.attr == 43 import importrace assert isinstance(importrace, apipkg.ApiModule) assert importrace.attr == 43 for thread in threads: assert thread.importrace is importrace def test_bpython_getattr_override(tmpdir, monkeypatch): def patchgetattr(self, name): raise AttributeError(name) monkeypatch.setattr(apipkg.ApiModule, "__getattr__", patchgetattr) api = apipkg.ApiModule( "bpy", { "abspath": "os.path:abspath", }, ) d = api.__dict__ assert "abspath" in d def test_chdir_with_relative_imports_support_lazy_loading(tmpdir, monkeypatch): monkeypatch.syspath_prepend(os.path.abspath("src")) pkg = tmpdir.mkdir("pkg") tmpdir.mkdir("messy") pkg.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, { 'test': '.sub:test', }) """ ) ) pkg.join("sub.py").write("def test(): pass") tmpdir.join("main.py").write( textwrap.dedent( """ from __future__ import print_function import os import sys sys.path.insert(0, '') import pkg print(pkg.__path__, file=sys.stderr) print(pkg.__file__, file=sys.stderr) print(pkg, file=sys.stderr) os.chdir('messy') pkg.test() assert os.path.isabs(pkg.sub.__file__), pkg.sub.__file__ """ ) ) res = subprocess.call( [sys.executable, "main.py"], cwd=str(tmpdir), ) assert res == 0 def test_dotted_name_lookup(tmpdir, monkeypatch): pkgdir = tmpdir.mkdir("dotted_name_lookup") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, dict(abs='os:path.abspath')) """ ) ) monkeypatch.syspath_prepend(tmpdir) import dotted_name_lookup assert dotted_name_lookup.abs == os.path.abspath def test_extra_attributes(tmpdir, monkeypatch): pkgdir = tmpdir.mkdir("extra_attributes") pkgdir.join("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, dict(abs='os:path.abspath'), dict(foo='bar')) """ ) ) monkeypatch.syspath_prepend(tmpdir) import extra_attributes assert extra_attributes.foo == "bar" def test_aliasmodule_aliases_an_attribute(): am = apipkg._alias_module.AliasModule("mymod", "pprint", "PrettyPrinter") r = repr(am) assert "" == r assert am.format assert not hasattr(am, "lqkje") def test_aliasmodule_aliases_unimportable_fails(): am = apipkg._alias_module.AliasModule("mymod", "qlwkejqlwe", "main") r = repr(am) assert "" == r # this would pass starting with apipkg 1.3 to work around a pytest bug with pytest.raises(ImportError): am.qwe is None def test_aliasmodule_pytest_autoreturn_none_for_hack(monkeypatch): def error(*k): raise ImportError(k) monkeypatch.setattr(apipkg._alias_module, "importobj", error) # apipkg 1.3 added this hack am = apipkg._alias_module.AliasModule("mymod", "pytest") r = repr(am) assert "" == r assert am.test is None def test_aliasmodule_unicode(): am = apipkg._alias_module.AliasModule("mymod", "pprint") assert am def test_aliasmodule_repr(): am = apipkg._alias_module.AliasModule("mymod", "sys") r = repr(am) assert "" == r am.version assert repr(am) == r def test_aliasmodule_proxy_methods(tmpdir, monkeypatch): pkgdir = tmpdir pkgdir.join("aliasmodule_proxy.py").write( textwrap.dedent( """ def doit(): return 42 """ ) ) pkgdir.join("my_aliasmodule_proxy.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, dict(proxy='aliasmodule_proxy')) def doit(): return 42 """ ) ) monkeypatch.syspath_prepend(tmpdir) import aliasmodule_proxy as orig # type: ignore from my_aliasmodule_proxy import proxy # type: ignore doit = proxy.doit assert doit is orig.doit del proxy.doit with pytest.raises(AttributeError): orig.doit proxy.doit = doit assert orig.doit is doit def test_aliasmodule_nested_import_with_from(tmpdir, monkeypatch): import os pkgdir = tmpdir.mkdir("api1") pkgdir.ensure("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, { 'os2': 'api2', 'os2.path': 'api2.path2', }) """ ) ) tmpdir.join("api2.py").write( textwrap.dedent( """ import os, sys from os import path sys.modules['api2.path2'] = path x = 3 """ ) ) monkeypatch.syspath_prepend(tmpdir) from api1 import os2 # type: ignore from api1.os2.path import abspath # type: ignore assert abspath == os.path.abspath # check that api1.os2 mirrors os.* assert os2.x == 3 import api1 # type: ignore assert "os2.path" not in api1.__dict__ def test_initpkg_without_old_module(): apipkg.initpkg("initpkg_without_old_module", dict(modules="sys:modules")) from initpkg_without_old_module import modules # type: ignore assert modules is sys.modules def test_get_distribution_version(): assert apipkg.distribution_version("setuptools") is not None assert apipkg.distribution_version("email") is None assert apipkg.distribution_version("pytest") is not None def test_eagerload_on_bython(monkeypatch): monkeypatch.delitem(sys.modules, "bpython", raising=False) apipkg.initpkg("apipkg.testmodule.example.lazy", {"test": "apipkg.does_not_exist"}) monkeypatch.setitem(sys.modules, "bpython", True) with pytest.raises(ImportError): apipkg.initpkg( "apipkg.testmodule.example.lazy", {"test": "apipkg.does_not_exist"} ) @pytest.fixture def find_spec(): try: from importlib.util import find_spec except ImportError: pytest.xfail("no importlib") return find_spec def test_importlib_find_spec_fake_module(find_spec): mod = apipkg.initpkg("apipkg.testmodule.example.missing", {}) with pytest.raises(ValueError, match=mod.__name__ + r"\.__spec__ is None"): find_spec(mod.__name__) def test_importlib_find_spec_aliasmodule(find_spec): am = apipkg._alias_module.AliasModule( "apipkg.testmodule.example.email_spec", "email" ) spec = find_spec(am.__name__) assert spec is am.__spec__ def test_importlib_find_spec_initpkg(find_spec, tmpdir, monkeypatch): modname = "apipkg_test_example_initpkg_findspec" pkgdir = tmpdir.mkdir("apipkg_test_example_initpkg_findspec") pkgdir.ensure("__init__.py").write( textwrap.dedent( """ import apipkg apipkg.initpkg(__name__, { 'email': 'email', }) """ ) ) monkeypatch.syspath_prepend(tmpdir) find_spec(modname) find_spec(modname + ".email") apipkg-3.0.2/tox.ini000066400000000000000000000004131450232246500143030ustar00rootroot00000000000000[tox] # https://github.com/pytest-dev/apipkg/issues/40 # this is to support https://src.fedoraproject.org/rpms/pyproject-rpm-macros/blob/rawhide/f/macros.pyproject#_171 envlist=py37,py38,py39,py310,py311 isolated_build = True [testenv] deps=pytest commands=pytest []