#!/usr/bin/env python3 import glob import importlib import json import io import tempfile import dataclasses import pathlib import sys import subprocess import os import logging import typing from typing import ( Optional, Any, cast, Type, TypeVar, Callable, ) from typing_extensions import ( Self, BinaryIO, overload, ) logger = logging.getLogger(__name__) def toml_load(f: BinaryIO) -> Any: try: tomllib = importlib.import_module('tomllib') return cast( Callable[[Any], Any], getattr( tomllib, 'load', ), )(f) except ModuleNotFoundError: pass try: import tomli return tomli.load(f) except ModuleNotFoundError: pass raise NotImplementedError @dataclasses.dataclass class PyProject: @dataclasses.dataclass class Module: name: str meson: Optional[pathlib.Path] = None tool: dict[str, Any] = dataclasses.field(default_factory=lambda: dict()) path: pathlib.Path dependencies: dict[str, list[str]] early_features: Optional[list[str]] = None pip_find_links: Optional[list[pathlib.Path]] = None runtime_libdirs: Optional[list[pathlib.Path]] = None runtime_preload: Optional[list[pathlib.Path]] = None @dataclasses.dataclass class ThirdPartyRoot: package: Optional[str] = None module_root: Optional[str] = None path: Optional[str] = None third_party_roots: list[ThirdPartyRoot] = dataclasses.field( default_factory=lambda: [], ) requirements: dict[str, pathlib.Path] = dataclasses.field(default_factory=lambda: dict()) modules: list[Module] = dataclasses.field( default_factory=lambda: [], ) tool: dict[str, Any] = dataclasses.field( default_factory=lambda: dict(), ) Key = TypeVar('Key') Value = TypeVar('Value') @overload def check_dict( value: Any, KT: Type[Key], VT: Type[Value], ) -> dict[Key, Value]: ... @overload def check_dict( value: Any, KT: Type[Key], ) -> dict[Key, Any]: ... def check_dict( value: Any, KT: Type[Key], VT: Optional[Type[Value]] = None, ) -> dict[Key, Value]: assert isinstance(value, dict) value2 = cast(dict[Any, Any], value) VT_class: Optional[type[Any]] = None if not VT is None: if not typing.get_origin(VT) is None: VT_class = cast(type[Any], typing.get_origin(VT)) else: VT_class = VT assert all([isinstance(k, KT) and (VT_class is None or isinstance(v, VT_class)) for k, v in value2.items()]) if VT is None: return cast( dict[Key, Any], value, ) else: return cast( dict[Key, Value], value, ) @overload def check_list( value: Any, VT: Type[Value], ) -> list[Value]: ... @overload def check_list( value: Any, ) -> list[Any]: ... def check_list( value: Any, VT: Optional[Type[Value]] = None, ) -> list[Value] | list[Any]: assert isinstance(value, list) value2 = cast(list[Any], value) assert all([(VT is None or isinstance(o, VT)) for o in value2]) if VT is None: return cast( list[Any], value, ) else: return cast( list[Value], value, ) def check_type( value: Any, VT: Type[Value], attribute_name: Optional[str] = None, ) -> Value: if attribute_name: attribute_value = getattr(value, attribute_name) assert isinstance(attribute_value, VT) return attribute_value else: assert isinstance(value, VT) return value def pyproject_load( d: pathlib.Path, ) -> PyProject: with io.open(d, 'rb') as f: content = toml_load(f) assert isinstance(content, dict) dependencies: dict[str, list[str]] = dict() dependencies['default'] = content['project']['dependencies'] if 'optional-dependencies' in content['project']: assert isinstance(content['project']['optional-dependencies'], dict) for k, v in check_dict( check_dict( check_dict( content, str, # Any, )['project'], str, # Any, )['optional-dependencies'], str, list[Any], ).items(): # assert isinstance(v, list) # assert isinstance(k, str) dependencies[k] = v res = PyProject( path=d, dependencies=dependencies, ) tool_name = 'online.fxreader.pr34'.replace('.', '-') if 'tool' in content: res.tool = check_dict( content['tool'], str, ) if 'tool' in content and isinstance(content['tool'], dict) and tool_name in content['tool'] and isinstance(content['tool'][tool_name], dict): pr34_tool = check_dict( check_dict( content['tool'], str, )[tool_name], str, ) if 'early_features' in pr34_tool: res.early_features = pr34_tool['early_features'] if 'pip_find_links' in pr34_tool: res.pip_find_links = [d.parent / pathlib.Path(o) for o in pr34_tool['pip_find_links']] if 'runtime_libdirs' in pr34_tool: res.runtime_libdirs = [ d.parent / pathlib.Path(o) # pathlib.Path(o) for o in check_list(pr34_tool['runtime_libdirs'], str) ] if 'runtime_preload' in pr34_tool: res.runtime_preload = [ d.parent / pathlib.Path(o) # pathlib.Path(o) for o in check_list(pr34_tool['runtime_preload'], str) ] if 'third_party_roots' in pr34_tool: for o in check_list(pr34_tool['third_party_roots']): o2 = check_dict(o, str, str) assert all([k in {'package', 'module_root', 'path'} for k in o2]) res.third_party_roots.append( PyProject.ThirdPartyRoot( package=o2.get('package'), module_root=o2.get('module_root'), path=o2.get('path'), ) ) if 'requirements' in pr34_tool: res.requirements = { k: d.parent / pathlib.Path(v) # pathlib.Path(o) for k, v in check_dict(pr34_tool['requirements'], str, str).items() } if 'modules' in pr34_tool: modules = check_list(pr34_tool['modules']) # res.modules = [] for o in modules: assert isinstance(o, dict) assert 'name' in o and isinstance(o['name'], str) module = PyProject.Module( name=o['name'], ) if 'meson' in o: assert 'meson' in o and isinstance(o['meson'], str) module.meson = pathlib.Path(o['meson']) if 'tool' in o: module.tool.update( check_dict( o['tool'], str, ) ) res.modules.append(module) return res @dataclasses.dataclass class BootstrapSettings: env_path: pathlib.Path python_path: pathlib.Path base_dir: pathlib.Path python_version: Optional[str] = dataclasses.field( default_factory=lambda: os.environ.get( 'PYTHON_VERSION', '%d.%d' % ( sys.version_info.major, sys.version_info.minor, ), ).strip() ) pip_check_conflicts: Optional[bool] = dataclasses.field( default_factory=lambda: os.environ.get('PIP_CHECK_CONFLICTS', json.dumps(True)) in [json.dumps(True)], ) uv_args: list[str] = dataclasses.field( default_factory=lambda: os.environ.get( 'UV_ARGS', '--offline -U', ).split(), ) @classmethod def get( cls, base_dir: Optional[pathlib.Path] = None, ) -> Self: if base_dir is None: base_dir = pathlib.Path.cwd() env_path: Optional[pathlib.Path] = None if 'ENV_PATH' in os.environ: env_path = pathlib.Path(os.environ['ENV_PATH']) else: env_path = base_dir / '.venv' python_path = env_path / 'bin' / 'python3' return cls( base_dir=base_dir, env_path=env_path, python_path=python_path, ) class requirements_name_get_t: @dataclasses.dataclass class res_t: not_compiled: pathlib.Path compiled: pathlib.Path name: str def requirements_name_get( source_dir: pathlib.Path, python_version: Optional[str], features: list[str], requirements: dict[str, pathlib.Path], ) -> requirements_name_get_t.res_t: requirements_python_version: Optional[str] = None if not python_version is None: requirements_python_version = python_version.replace('.', '_') requirements_name = '_'.join(sorted(features)) if requirements_python_version: requirements_name += '_' + requirements_python_version requirements_path: Optional[pathlib.Path] = None if requirements_name in requirements: requirements_path = requirements[requirements_name] else: requirements_path = source_dir / 'requirements.txt' requirements_path_in = requirements_path.parent / (requirements_path.stem + '.in') requirements_in: list[str] = [] return requirements_name_get_t.res_t( not_compiled=requirements_path_in, compiled=requirements_path, name=requirements_name, ) def env_bootstrap( bootstrap_settings: BootstrapSettings, pyproject: PyProject, ) -> None: pip_find_links: list[pathlib.Path] = [] if not pyproject.pip_find_links is None: pip_find_links.extend(pyproject.pip_find_links) pip_find_links_args = sum( [ [ '-f', str(o), ] for o in pip_find_links ], cast(list[str], []), ) features: list[str] = [] if pyproject.early_features: features.extend(pyproject.early_features) requirements_name_get_res = requirements_name_get( python_version=bootstrap_settings.python_version, features=features, requirements=pyproject.requirements, source_dir=pyproject.path.parent, ) requirements_path = requirements_name_get_res.compiled requirements_in: list[str] = [] requirements_in.extend(['uv', 'pip', 'build', 'setuptools', 'meson-python', 'pybind11']) if pyproject.early_features: early_dependencies = sum([pyproject.dependencies[o] for o in pyproject.early_features], cast(list[str], [])) logger.info( dict( requirements_name_get_res=requirements_name_get_res, early_dependencies=early_dependencies, ) ) requirements_in.extend(early_dependencies) # if len(early_dependencies) > 0: # subprocess.check_call([ # bootstrap_settings.python_path, # '-m', # 'uv', 'pip', 'install', # *pip_find_links_args, # # '-f', str(pathlib.Path(__file__).parent / 'deps' / 'dist'), # *bootstrap_settings.uv_args, # *early_dependencies, # ]) uv_python_version: list[str] = [] venv_python_version: list[str] = [] if not bootstrap_settings.python_version is None: uv_python_version.extend( [ # '-p', '--python-version', bootstrap_settings.python_version, ] ) venv_python_version.extend( [ '-p', # '--python-version', bootstrap_settings.python_version, ] ) if not requirements_path.exists(): with tempfile.NamedTemporaryFile( mode='w', prefix='requirements', suffix='.in', ) as f: f.write('\n'.join(requirements_in)) f.flush() subprocess.check_call( [ 'uv', 'pip', 'compile', *uv_python_version, '--generate-hashes', *pip_find_links_args, # '-p', # bootstrap_settings.python_path, *bootstrap_settings.uv_args, '-o', str(requirements_path), f.name, ] ) subprocess.check_call( [ 'uv', *[o for o in bootstrap_settings.uv_args if not o in ['-U', '--upgrade']], 'venv', *venv_python_version, *pip_find_links_args, # '--seed', str(bootstrap_settings.env_path), ] ) subprocess.check_call( [ 'uv', 'pip', 'install', *uv_python_version, *pip_find_links_args, '-p', bootstrap_settings.python_path, '--require-hashes', *bootstrap_settings.uv_args, '-r', str(requirements_path), ] ) if bootstrap_settings.pip_check_conflicts: subprocess.check_call( [ bootstrap_settings.python_path, '-m', 'online.fxreader.pr34.commands', 'pip_check_conflicts', ] ) def paths_equal(a: pathlib.Path | str, b: pathlib.Path | str) -> bool: return os.path.abspath(str(a)) == os.path.abspath(str(b)) def run( d: Optional[pathlib.Path] = None, cli_path: Optional[pathlib.Path] = None, ) -> None: if cli_path is None: cli_path = pathlib.Path(__file__).parent / 'cli.py' if d is None: d = pathlib.Path(__file__).parent / 'pyproject.toml' bootstrap_settings = BootstrapSettings.get() pyproject: PyProject = pyproject_load(d) logging.basicConfig(level=logging.INFO) if not bootstrap_settings.env_path.exists(): env_bootstrap( bootstrap_settings=bootstrap_settings, pyproject=pyproject, ) logger.info([sys.executable, sys.argv, bootstrap_settings.python_path]) if not paths_equal(sys.executable, bootstrap_settings.python_path): os.execv( str(bootstrap_settings.python_path), [ str(bootstrap_settings.python_path), *sys.argv, ], ) os.execv( str(bootstrap_settings.python_path), [ str(bootstrap_settings.python_path), str(cli_path), *sys.argv[1:], ], ) if __name__ == '__main__': run( d=pathlib.Path(__file__).parent / 'pyproject.toml', cli_path=pathlib.Path(__file__).parent / 'cli.py', )