Files

466 lines
19 KiB
Python

"""Base class for Module and ConstantsModule."""
from __future__ import annotations
import ast
import socket
from contextlib import suppress
from datetime import datetime, timezone
from functools import cached_property, partial
from importlib import import_module
from importlib.machinery import EXTENSION_SUFFIXES
from keyword import iskeyword
from pathlib import Path
from typing import TYPE_CHECKING
from packaging.requirements import Requirement
from cx_Freeze._compat import IS_MINGW, IS_WINDOWS
from cx_Freeze._importlib import metadata
from cx_Freeze.exception import ModuleError, OptionError
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from types import CodeType
__all__ = ["ConstantsModule", "Module", "ModuleHook"]
class DistributionCache(metadata.PathDistribution):
"""Cache the distribution package."""
def __init__(self, cache_path: Path, name: str) -> None:
"""Construct a distribution.
:param cache_path: Path indicating where to store the cache.
:param name: The name of the distribution package to cache.
:raises ModuleError: When the named package's distribution
metadata cannot be found.
"""
try:
distribution = metadata.PathDistribution.from_name(name)
except metadata.PackageNotFoundError:
distribution = None
if distribution is None:
raise ModuleError(name)
# Cache dist-info files in a temporary directory
normalized_name = getattr(distribution, "_normalized_name", None)
if normalized_name is None:
normalized_name = metadata.Prepared.normalize(name)
source_path = getattr(distribution, "_path", None)
if source_path is None:
mask = f"{normalized_name}-{distribution.version}.*-info"
dist_path = list(distribution.locate_file(".").glob(mask))
if not dist_path:
mask = f"{name}-{distribution.version}.*-info"
dist_path = list(distribution.locate_file(".").glob(mask))
if dist_path:
source_path = dist_path[0]
if source_path is None or not source_path.exists():
raise ModuleError(name)
dist_name = f"{normalized_name}-{distribution.version}.dist-info"
target_path = cache_path / dist_name
super().__init__(target_path)
self.original = distribution
self.normalized_name = normalized_name
self.distinfo_name = dist_name
if target_path.exists(): # cached
return
target_path.mkdir(parents=True)
purelib = None
if source_path.name.endswith(".dist-info"):
for source in source_path.rglob("*"): # type: Path
target = target_path / source.relative_to(source_path)
if source.is_dir():
target.mkdir(exist_ok=True)
else:
target.write_bytes(source.read_bytes())
elif source_path.is_file():
# old egg-info file is converted to dist-info
target = target_path / "METADATA"
target.write_bytes(source_path.read_bytes())
purelib = (source_path.parent / (normalized_name + ".py")).exists()
else:
# Copy minimal data from egg-info directory into dist-info
source = source_path / "PKG-INFO"
if source.is_file():
target = target_path / "METADATA"
target.write_bytes(source.read_bytes())
source = source_path / "entry_points.txt"
if source.is_file():
target = target_path / "entry_points.txt"
target.write_bytes(source.read_bytes())
source = source_path / "top_level.txt"
if source.is_file():
target = target_path / "top_level.txt"
target.write_bytes(source.read_bytes())
purelib = not source_path.joinpath("not-zip-safe").is_file()
self._write_wheel_distinfo(purelib)
self._write_record_distinfo()
def _write_wheel_distinfo(self, purelib: bool) -> None:
"""Create a WHEEL file if it doesn't exist."""
target = self.locate_file(f"{self.distinfo_name}/WHEEL")
if not target.exists():
project = Path(__file__).parent.name # cx_Freeze
version = metadata.version(project)
root_is_purelib = "true" if purelib else "false"
text = [
"Wheel-Version: 1.0",
f"Generator: {project} ({version})",
f"Root-Is-Purelib: {root_is_purelib}",
"Tag: py3-none-any",
]
with target.open(mode="w", encoding="utf_8", newline="") as file:
file.write("\n".join(text))
def _write_record_distinfo(self) -> None:
"""Recreate a minimal RECORD file."""
distinfo_name = self.distinfo_name
target = self.locate_file(f"{distinfo_name}/RECORD")
target_dir = target.parent
record = [
f"{distinfo_name}/{file.name},," for file in target_dir.iterdir()
]
record.append(f"{distinfo_name}/RECORD,,")
with target.open(mode="w", encoding="utf_8", newline="") as file:
file.write("\n".join(record))
@property
def binary_files(self) -> list[str]:
"""Return the binary files included in the package."""
if IS_MINGW or IS_WINDOWS:
return [
file
for file in self.original.files
if file.suffix.lower() == ".dll"
]
extensions = tuple([ext for ext in EXTENSION_SUFFIXES if ext != ".so"])
return [
file
for file in self.original.files
if file.match("*.so*") and not file.name.endswith(extensions)
]
@property
def installer(self) -> str:
"""Return the installer (pip, conda) for the distribution package."""
# consider 'uv' as 'pip'
value = self.read_text("INSTALLER") or "pip"
return value.splitlines()[0].replace("uv", "pip")
@property
def requires(self) -> list[str]:
"""Generated requirements specified for this Distribution."""
package_names = []
requires = super().requires
if requires:
for requirement_string in requires:
require = Requirement(requirement_string)
if require.marker is None or require.marker.evaluate():
package_names.append(require.name)
return package_names
@property
def version(self) -> tuple[int, ...] | str | None:
"""Return the 'Version' metadata for the distribution package."""
value = super().version
with suppress(ValueError):
value = tuple(map(int, value.split(".")))
return value
class Module:
"""The Module class."""
def __init__(
self,
name: str,
path: Sequence[Path | str] | None = None,
filename: Path | str | None = None,
parent: Module | None = None,
) -> None:
self.name: str = name
self.path: list[Path] | None = list(map(Path, path)) if path else None
self._file: Path | None = self._file_validate(filename)
self.parent: Module | None = parent
self.root: Module = parent.root if parent else self
self.code: CodeType | None = None
self.cache_path: Path | None = None
self.distribution: DistributionCache | None = None
self.hook: ModuleHook | Callable | None = None
self.exclude_names: set[str] = set()
self.global_names: set[str] = set()
self.ignore_names: set[str] = set()
self.in_import: bool = True
self.source_is_string: bool = False
self.source_is_zip_file: bool = False
self._in_file_system: int = 1
# add the load hook
self.load_hook()
def __repr__(self) -> str:
parts = [f"name={self.name!r}"]
if self.file is not None:
parts.append(f"file={self.file!r}")
if self.path is not None:
parts.append(f"path={self.path!r}")
join_parts = ", ".join(parts)
return f"<Module {join_parts}>"
@property
def file(self) -> Path | None:
"""Module filename."""
return self._file
@file.setter
def file(self, filename: Path | str | None) -> None:
self._file = self._file_validate(filename)
def _file_validate(self, filename: Path | str | None) -> Path | None:
if "stub_code" in self.__dict__:
del self.__dict__["stub_code"] # clear the cache
if not filename:
return None
if isinstance(filename, str):
filename = Path(filename)
return filename
@cached_property
def stub_code(self) -> CodeType | None:
cache_path: Path = self.cache_path
filename = self._file
if filename is None:
return None
ext = "".join(filename.suffixes)
if ext not in EXTENSION_SUFFIXES:
return None
source_dir = self.root.file.parent
package = filename.parent.relative_to(source_dir.parent)
stem = filename.name.partition(ext)[0]
stub_name = f"{stem}.pyi"
# search for the stub file already parsed in the distribution
importshed = Path(__file__).resolve().parent / "importshed"
source_file = importshed / package / stub_name
if source_file.exists():
imports_only = source_file.read_text(encoding="utf_8")
if imports_only:
return compile(
imports_only, stub_name, "exec", dont_inherit=True
)
# search for a stub file along side the python extension module
source_file = filename.parent / stub_name
if not source_file.exists():
return None
if cache_path:
target_file = cache_path / package / stub_name
if target_file.exists():
# a parsed stub exists in the cache
imports_only = target_file.read_text(encoding="utf_8")
else:
imports_only = self.get_imports_from_file(source_file)
if imports_only:
# cache the parsed stub
target_file.parent.mkdir(parents=True, exist_ok=True)
target_file.write_text(
"# Generated by cx_Freeze\n\n" + imports_only,
encoding="utf_8",
)
else:
imports_only = self.get_imports_from_file(source_file)
if imports_only:
return compile(imports_only, stub_name, "exec", dont_inherit=True)
return None
def get_imports_from_file(self, source_file: Path) -> str | None:
"""Get the implicit imports in a stub file."""
if not source_file.is_file():
return None
ignore = {"__future__", "builtins", "typing", "_typeshed"}
source = source_file.read_text(encoding="utf_8")
try:
rootnode = ast.parse(source, source_file.name)
except SyntaxError:
return None
lines = []
for node in ast.walk(rootnode):
if isinstance(node, ast.Import):
names = {name.name for name in node.names}
names.difference_update(ignore)
if names:
lines.append("import " + ", ".join(sorted(names)))
elif isinstance(node, ast.ImportFrom):
if node.level == 0 and node.module in ignore:
continue
names = {name.name for name in node.names}
line = "from "
if node.level > 0:
line += "." * node.level
if node.module:
line += node.module
line += " import " + ", ".join(sorted(names))
lines.append(line)
return "\n".join([*lines, ""]) if lines else None
@property
def in_file_system(self) -> int:
"""Returns a value indicating where the module/package will be stored:
0. in a zip file (not directly in the file system)
1. in the file system, package with modules and data
2. in the file system, only detected modules.
"""
if self.parent is not None:
return self.parent.in_file_system
if self.path is None or self.file is None:
return 0
return self._in_file_system
@in_file_system.setter
def in_file_system(self, value: int) -> None:
self._in_file_system: int = value
def load_hook(self) -> None:
"""Load hook for the given module if one is present.
For instance, a load hook for PyQt5.QtCore:
- Using ModuleHook class:
# module and hook methods are lowercased.
hook = pyqt5.Hook()
hook.qt_qtcore()
- For functions present in hooks.__init__:
# module and load hook functions use the original case.
load_PyQt5_QtCore()
- For functions in a separated module:
# module and load hook functions are lowercased.
pyqt5.load_pyqt5_qtcore()
"""
if not isinstance(self.root.hook, ModuleHook):
try:
# new style hook using ModuleHook class - top-level call
root_name = self.root.name.lower()
hooks = import_module(f"cx_Freeze.hooks.{root_name}")
hook_cls = getattr(hooks, "Hook", None)
if hook_cls and issubclass(hook_cls, ModuleHook):
self.root.hook = hook_cls(self.root)
else:
# old style hook with lowercased functions
name = self.name.replace(".", "_").lower()
func = getattr(hooks, f"load_{name}", None)
self.hook = partial(func, module=self) if func else None
return
except ImportError:
# old style hook with functions at hooks.__init__
hooks = import_module("cx_Freeze.hooks")
name = self.name.replace(".", "_")
func = getattr(hooks, f"load_{name}", None)
self.hook = partial(func, module=self) if func else None
return
# new style hook using ModuleHook class - lower level call
root_hook = self.root.hook
if isinstance(root_hook, ModuleHook) and self.parent is not None:
name = "_".join(self.name.lower().split(".")[1:])
func = getattr(root_hook, f"{root_hook.name}_{name}", None)
self.hook = partial(func, module=self) if func else None
def update_distribution(self, name: str | None = None) -> None:
"""Update the distribution cache based on its name.
This method may be used to link an distribution's name to a module.
Example: ModuleFinder cannot detects the distribution of _cffi_backend
but in a hook we can link it to 'cffi'.
"""
cache_path: Path = self.cache_path
if cache_path is None:
return
if name is None:
name = self.name
try:
distribution = DistributionCache(cache_path, name)
except ModuleError:
return
for req_name in distribution.requires:
with suppress(ModuleError):
DistributionCache(cache_path, req_name)
self.distribution = distribution
class ModuleHook:
"""The Module Hook class."""
def __init__(self, module: Module) -> None:
self.module = module
self.name = module.name.replace(".", "_").lower()
def __call__(self, finder) -> None:
# redirect to the top level hook
method = getattr(self, self.name, None)
if method:
method(finder, self.module)
class ConstantsModule:
"""Base ConstantsModule class."""
def __init__(
self,
release_string: str | None = None,
copyright_string: str | None = None,
module_name: str = "BUILD_CONSTANTS",
time_format: str = "%B %d, %Y %H:%M:%S",
constants: list[str] | None = None,
) -> None:
self.module_name: str = module_name
self.time_format: str = time_format
self.values: dict[str, str] = {}
self.values["BUILD_RELEASE_STRING"] = release_string
self.values["BUILD_COPYRIGHT"] = copyright_string
if constants:
for constant in constants:
parts = constant.split("=", maxsplit=1)
if len(parts) == 1:
name = constant
value = None
else:
name, string_value = parts
value = ast.literal_eval(string_value)
if (not name.isidentifier()) or iskeyword(name):
msg = (
f"Invalid constant name in ConstantsModule ({name!r})"
)
raise OptionError(msg)
self.values[name] = value
def create(self, tmp_path: Path, modules: list[Module]) -> Path:
"""Create the module which consists of declaration statements for each
of the values.
"""
today = datetime.now(tz=timezone.utc)
source_timestamp = 0
for module in modules:
if module.file is None or module.source_is_string:
continue
if module.source_is_zip_file:
continue
if not module.file.exists():
msg = (
f"No file named {module.file!s} (for module {module.name})"
)
raise OptionError(msg)
timestamp = module.file.stat().st_mtime
source_timestamp = max(source_timestamp, timestamp)
stamp = datetime.fromtimestamp(source_timestamp, tz=timezone.utc)
self.values["BUILD_TIMESTAMP"] = today.strftime(self.time_format)
self.values["BUILD_HOST"] = socket.gethostname().split(".")[0]
self.values["SOURCE_TIMESTAMP"] = stamp.strftime(self.time_format)
parts = []
for name in sorted(self.values.keys()):
value = self.values[name]
parts.append(f"{name} = {value!r}")
module_path = tmp_path.joinpath(self.module_name).with_suffix(".py")
with module_path.open(mode="w", encoding="utf_8", newline="") as file:
file.write("\n".join(parts))
return module_path