-
-
Notifications
You must be signed in to change notification settings - Fork 43
Add add_mock_package #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
525d0d7
111046f
799560d
f66370c
43fa3f3
16739f5
0a6471a
5e5b0d9
0eb9803
20b74f0
c76a8e6
3418848
ea8c424
6628396
9cb4ac2
d6aab95
005efec
a12cd86
10c7f84
5d96d84
786e5e8
35539ef
107004d
ac8bc37
98b9192
e83cb59
4d719c2
fd037ae
3b77d88
782b6af
d34ff58
5e8f5e6
2c34df0
9c16b28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,23 @@ | ||
| from ._micropip import _list as list | ||
| from ._micropip import freeze, install | ||
| from ._micropip import ( | ||
| add_mock_package, | ||
| freeze, | ||
| install, | ||
| list_mock_packages, | ||
| remove_mock_package, | ||
| ) | ||
|
|
||
| try: | ||
| from ._version import __version__ | ||
| except ImportError: | ||
| pass | ||
|
|
||
| __all__ = ["install", "list", "freeze", "__version__"] | ||
| __all__ = [ | ||
| "install", | ||
| "list", | ||
| "freeze", | ||
| "add_mock_package", | ||
| "list_mock_packages", | ||
| "remove_mock_package", | ||
| "__version__", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,14 +2,19 @@ | |
| import hashlib | ||
| import importlib | ||
| import json | ||
| import shutil | ||
| import site | ||
| import sys | ||
| import warnings | ||
| from asyncio import gather | ||
| from dataclasses import dataclass, field | ||
| from importlib.metadata import PackageNotFoundError | ||
| from importlib.metadata import distribution as importlib_distribution | ||
| from importlib.metadata import distributions as importlib_distributions | ||
| from importlib.metadata import version as importlib_version | ||
| from pathlib import Path | ||
| from sysconfig import get_platform | ||
| from textwrap import dedent | ||
| from typing import IO, Any | ||
| from urllib.parse import ParseResult, urlparse | ||
| from zipfile import ZipFile | ||
|
|
@@ -20,6 +25,7 @@ | |
| from packaging.utils import canonicalize_name, parse_wheel_filename | ||
| from packaging.version import Version | ||
|
|
||
| from . import _mock_package | ||
| from ._compat import ( | ||
| REPODATA_INFO, | ||
| REPODATA_PACKAGES, | ||
|
|
@@ -91,7 +97,7 @@ def is_compatible(self): | |
| return True | ||
| return False | ||
|
|
||
| def check_compatible(self): | ||
| def check_compatible(self) -> None: | ||
| if self.is_compatible(): | ||
| return | ||
| tag: Tag = next(iter(self.tags)) | ||
|
|
@@ -692,3 +698,170 @@ def _list(): | |
| source_ = pkg_source | ||
| packages[name] = PackageMetadata(name=name, version=version, source=source_) | ||
| return packages | ||
|
|
||
|
|
||
| MOCK_INSTALL_NAME_MEMORY = "micropip in-memory mock package" | ||
| MOCK_INSTALL_NAME_PERSISTENT = "micropip mock package" | ||
|
|
||
|
|
||
| def add_mock_package( | ||
| name: str, version: str, *, modules: dict | None = None, persistent: bool = False | ||
| ) -> None: | ||
| """ | ||
| Add a mock version of a package to the package dictionary. | ||
|
|
||
| This means that if it is a dependency, it is skipped on install. | ||
|
|
||
| By default a single empty module is installed with the same | ||
| name as the package. You can alternatively give one or more modules to make a | ||
| set of named modules. | ||
|
|
||
| The modules parameter is usually a dictionary mapping module name to module text. | ||
|
|
||
| e.g. | ||
| `` | ||
| { | ||
| "mylovely_module":''' | ||
| def module_method(an_argument): | ||
| print("This becomes a module level argument") | ||
| module_value = "this value becomes a module level variable" | ||
|
|
||
| print("This is run on import of module") | ||
| ''' | ||
| } | ||
|
|
||
| If you are adding the module in non-persistent mode, you can also pass functions | ||
| which are used to initialize the module on loading (as in `importlib.abc.loader.exec_module` ). | ||
| This allows you to do things like use `unittest.mock.MagicMock` classes for modules. | ||
|
|
||
| e.g. | ||
| `` | ||
| def init_fn(module): | ||
| module.dict["WOO"]="hello" | ||
| print("Initing the module now!") | ||
|
|
||
| ... | ||
| ... | ||
|
|
||
| { | ||
| "mylovely_module": init_fn | ||
| } | ||
| `` | ||
|
|
||
| Parameters | ||
| ---------- | ||
| name : ``str `` | ||
|
|
||
| Package name to add | ||
|
|
||
| version : ``str `` | ||
|
|
||
| Version of the package. This should be a semantic version string, | ||
| e.g. 1.2.3 | ||
|
|
||
| modules : ``Optional(dict) `` | ||
|
joemarshall marked this conversation as resolved.
|
||
|
|
||
| Dictionary of module_name:string pairs. | ||
| The string contains the source of the mock module or is blank for | ||
| an empty module. | ||
|
|
||
| persistent: ``boolean `` | ||
|
|
||
| If this is True, modules will be written to the file system, so they | ||
| persist between runs of python (assuming the file system persists). | ||
| If it is False, modules will be stored inside micropip in memory only. | ||
| """ | ||
| if modules is None: | ||
| # make a single mock module with this name | ||
| modules = {name: ""} | ||
| # make the metadata | ||
| METADATA = f"""Metadata-Version: 1.1 | ||
| Name: {name} | ||
| Version: {version} | ||
| Summary: {name} mock package generated by micropip | ||
| Author-email: {name}@micro.pip.non-working-fake-host | ||
| """ | ||
| for n in modules.keys(): | ||
| METADATA += f"Provides:{n}\n" | ||
|
|
||
| if persistent: | ||
| # make empty mock modules with the requested names in user site packages | ||
| site_packages = Path(site.getusersitepackages()) | ||
| # in pyodide site packages isn't on sys.path initially | ||
| if not site_packages.exists(): | ||
| site_packages.mkdir(parents=True, exist_ok=True) | ||
| if site_packages not in sys.path: | ||
| sys.path.append(str(site_packages)) | ||
| metadata_dir = site_packages / (name + "-" + version + ".dist-info") | ||
| metadata_dir.mkdir(parents=True, exist_ok=False) | ||
| metadata_file = metadata_dir / "METADATA" | ||
| record_file = metadata_dir / "RECORD" | ||
| installer_file = metadata_dir / "INSTALLER" | ||
| file_list = [] | ||
| file_list.append(metadata_file) | ||
| file_list.append(installer_file) | ||
| with open(metadata_file, "w") as mf: | ||
| mf.write(METADATA) | ||
| for n, s in modules.items(): | ||
| if s is None: | ||
| s = "" | ||
| s = dedent(s) | ||
| path_parts = n.split(".") | ||
| dir_path = Path(site_packages, *path_parts) | ||
| dir_path.mkdir(exist_ok=True, parents=True) | ||
| init_file = dir_path / "__init__.py" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we actually need to create these physical files? I was thinking we could maybe directly initialize Though yes, the API to create modules dynamically via
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe making another custom Finder/Loader would be a good way to go. The API for that isn't too bad.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I actually wrote it initially using mock objects and putting them into sys.modules, but I reverted to writing them out to files. I did it like that because it is really simple and reliable, things like file paths etc. just work. It also works nicely in terms of loading modules, in that init code is only run on actual import. Oh and in the case that you're running somewhere with persistent file system, your mock modules continue to exist on reload of python. It's a pretty minor change - you just create a module and exec the code into it in the loader, I can put that back in if you reckon the possibility of using mock objects makes sense.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually I think rather than take an object it needs to take a callable which takes a module object and does whatever needs to be done on it to make it. I.e. the equivalent of loader.exec_module That way you can mock module initialisation correctly.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One further thought on this - at some point I want to implement some kind of mega wheel option, to bundle a set of wheels and all deps into a single wheel with no deps. For that use case keeping persistent mock modules would be nice. If it's okay I'd like to keep that as an option in micropip? It would also be nice for any point that pyodide is run in a persistent file system. I'm thinking that a flag "persistent" could be added (which only works for string modules) and then the behaviour with finder and loader could be used in the case that the flag isn't set. |
||
| file_list.append(init_file) | ||
| with open(init_file, "w") as f: | ||
| f.write(s) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should keep the list of mocked modules, so it can be accessed via e.g.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added list_mock_modules instead - which uses module installer metadata to list modules it has installed |
||
| with open(installer_file, "w") as f: | ||
| f.write(MOCK_INSTALL_NAME_PERSISTENT) | ||
| with open(record_file, "w") as f: | ||
| for file in file_list: | ||
| f.write(f"{str(file)},,{file.stat().st_size}\n") | ||
| f.write(f"{str(record_file)},,\n") | ||
| else: | ||
| # make memory mocks of files | ||
| INSTALLER = MOCK_INSTALL_NAME_MEMORY | ||
| metafiles = {"METADATA": METADATA, "INSTALLER": INSTALLER} | ||
| _mock_package.add_in_memory_distribution(name, metafiles, modules) | ||
| importlib.invalidate_caches() | ||
|
|
||
|
|
||
| def list_mock_packages() -> list[str]: | ||
| packages = [] | ||
| for dist in importlib_distributions(): | ||
| installer = dist.read_text("INSTALLER") | ||
| if installer is not None and ( | ||
| installer == MOCK_INSTALL_NAME_PERSISTENT | ||
| or installer == MOCK_INSTALL_NAME_MEMORY | ||
| ): | ||
| packages.append(dist.name) | ||
| return packages | ||
|
|
||
|
|
||
| def remove_mock_package(name: str) -> None: | ||
| d = importlib_distribution(name) | ||
| installer = d.read_text("INSTALLER") | ||
| if installer == MOCK_INSTALL_NAME_MEMORY: | ||
| _mock_package.remove_in_memory_distribution(name) | ||
| return | ||
| elif installer is None or installer != MOCK_INSTALL_NAME_PERSISTENT: | ||
| raise ValueError( | ||
| f"Package {name} doesn't seem to be a micropip mock. \n" | ||
| "Are you sure it was installed with micropip?" | ||
| ) | ||
| # a real mock package - kill it | ||
| # remove all files | ||
| folders: set[Path] = set() | ||
| if d.files is not None: | ||
| for file in d.files: | ||
| p = Path(file.locate()) | ||
| p.unlink() | ||
| folders.add(p.parent) | ||
| # delete all folders except site_packages | ||
| # (that check is just to avoid killing | ||
| # undesirable things in case of weird micropip errors) | ||
| site_packages = Path(site.getusersitepackages()) | ||
| for f in folders: | ||
| if f != site_packages: | ||
| shutil.rmtree(f) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import importlib.abc | ||
| import importlib.metadata | ||
| import importlib.util | ||
| import sys | ||
| from collections.abc import Callable | ||
| from textwrap import dedent | ||
|
|
||
|
|
||
| class MockDistribution(importlib.metadata.Distribution): | ||
| def __init__(self, file_dict, modules): | ||
| self.file_dict = file_dict | ||
| self.modules = modules | ||
|
|
||
| def read_text(self, filename): | ||
| """Attempt to load metadata file given by the name. | ||
| :param filename: The name of the file in the distribution info. | ||
| :return: The text if found, otherwise None. | ||
| """ | ||
| if filename in self.file_dict: | ||
| return self.file_dict[filename] | ||
| else: | ||
| return None | ||
|
|
||
| def locate_file(self, path): | ||
| """ | ||
| Given a path to a file in this distribution, return a path | ||
| to it. | ||
| """ | ||
| return None | ||
|
|
||
|
|
||
| _mock_modules: "dict[str,str|Callable]" = {} | ||
| _mock_distributions: dict[str, MockDistribution] = {} | ||
|
|
||
|
|
||
| class _MockModuleFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader): | ||
| def __init__(self): | ||
| pass | ||
|
|
||
| def find_distributions(self, context): | ||
| if context.name in _mock_distributions: | ||
| return [_mock_distributions[context.name]] | ||
| elif context.name is None: | ||
| return _mock_distributions.values() | ||
| else: | ||
| return [] | ||
|
|
||
| def find_module(self, fullname, path=None): | ||
| spec = self.find_spec(fullname, path) | ||
| if spec is None: | ||
| return None | ||
| return spec | ||
|
|
||
| def create_module(self, spec): | ||
| if spec.name in _mock_modules: | ||
| from types import ModuleType | ||
|
|
||
| module = ModuleType(spec.name) | ||
| module.__path__ = "/micropip_mocks/" + module.__name__.replace(".", "/") | ||
| return module | ||
|
|
||
| def exec_module(self, module): | ||
| init_object = _mock_modules[module.__name__] | ||
| if isinstance(init_object, str): | ||
| # run module init code in the module | ||
| exec(dedent(init_object), module.__dict__) | ||
| elif callable(init_object): | ||
| # run module init function | ||
| init_object(module) | ||
|
|
||
| def find_spec(self, fullname, path=None, target=None): | ||
| if fullname not in _mock_modules.keys(): | ||
| return None | ||
| spec = importlib.util.spec_from_loader(fullname, self) | ||
| return spec | ||
|
|
||
|
|
||
| _finder = _MockModuleFinder() | ||
|
|
||
|
|
||
| def add_in_memory_distribution(name, metafiles, modules): | ||
| if _finder not in sys.meta_path: | ||
| sys.meta_path = [_finder] + sys.meta_path | ||
| _mock_distributions[name] = MockDistribution(metafiles, modules) | ||
| for name, obj in modules.items(): | ||
| _add_mock_module(name, obj) | ||
|
|
||
|
|
||
| def _add_mock_module(name, obj): | ||
| _mock_modules[name] = obj | ||
|
|
||
|
|
||
| def remove_in_memory_distribution(name): | ||
| if name in _mock_distributions: | ||
| for module in _mock_distributions[name].modules.keys(): | ||
| if module in sys.modules: | ||
| del sys.modules[module] | ||
| del _mock_modules[module] | ||
| del _mock_distributions[name] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there is an indentation issue in this docstring, but we can fix it once we start actually building docs for it.