tools.install
Installation script for config-ninja, based on the official Poetry installer.
1"""Installation script for `config-ninja`_, based on the official `Poetry installer`_. 2 3.. _config-ninja: https://bryant-finney.github.io/config-ninja/config_ninja.html 4.. _Poetry installer: https://github.com/python-poetry/install.python-poetry.org/blob/d62875fc05fb20062175cd14d19a96dbefa48640/install-poetry.py 5""" 6 7from __future__ import annotations 8 9import sys 10 11RC_INVALID_PYTHON = 1 12RC_PATH_EXISTS = 2 13 14# Eager version check so we fail nicely before possible syntax errors 15if sys.version_info < (3, 8): # noqa: UP036 16 sys.stdout.write('config-ninja installer requires Python 3.8 or newer to run!\n') 17 sys.exit(RC_INVALID_PYTHON) 18 19# pylint: disable=wrong-import-position,import-outside-toplevel 20 21import argparse 22import contextlib 23import copy 24import importlib 25import json 26import os 27import re 28import runpy 29import shutil 30import subprocess 31import sysconfig 32import tempfile 33import urllib.request 34from dataclasses import dataclass 35from pathlib import Path 36from typing import Any, Literal 37from urllib.request import Request 38 39# note: must be synchronized with 'tool.poetry.extras' in pyproject.toml 40PACKAGE_EXTRAS = ['all', 'appconfig', 'local'] 41 42MACOS = sys.platform == 'darwin' 43MINGW = sysconfig.get_platform().startswith('mingw') 44SHELL = os.getenv('SHELL', '') 45USER_AGENT = 'Python Config Ninja' 46WINDOWS = sys.platform.startswith('win') or (sys.platform == 'cli' and os.name == 'nt') 47 48 49def _get_win_folder_from_registry( 50 csidl_name: Literal['CSIDL_APPDATA', 'CSIDL_COMMON_APPDATA', 'CSIDL_LOCAL_APPDATA'], 51) -> Any: # pragma: no cover 52 import winreg as _winreg # pylint: disable=import-error 53 54 shell_folder_name = { 55 'CSIDL_APPDATA': 'AppData', 56 'CSIDL_COMMON_APPDATA': 'Common AppData', 57 'CSIDL_LOCAL_APPDATA': 'Local AppData', 58 }[csidl_name] 59 60 key = _winreg.OpenKey( # type: ignore[attr-defined,unused-ignore] 61 _winreg.HKEY_CURRENT_USER, # type: ignore[attr-defined,unused-ignore] 62 r'Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders', 63 ) 64 path, _ = _winreg.QueryValueEx(key, shell_folder_name) # type: ignore[attr-defined,unused-ignore] 65 66 return path # pyright: ignore[reportUnknownVariableType] 67 68 69def _get_win_folder_with_ctypes( 70 csidl_name: Literal['CSIDL_APPDATA', 'CSIDL_COMMON_APPDATA', 'CSIDL_LOCAL_APPDATA'], 71) -> Any: # pragma: no cover 72 import ctypes # pylint: disable=import-error 73 74 csidl_const = { 75 'CSIDL_APPDATA': 26, 76 'CSIDL_COMMON_APPDATA': 35, 77 'CSIDL_LOCAL_APPDATA': 28, 78 }[csidl_name] 79 80 buf = ctypes.create_unicode_buffer(1024) 81 ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf) # type: ignore[attr-defined,unused-ignore] 82 83 # Downgrade to short path name if have highbit chars. See 84 # <http://bugs.activestate.com/show_bug.cgi?id=85099>. 85 has_high_char = False 86 for c in buf: 87 if ord(c) > 255: # noqa: PLR2004 88 has_high_char = True 89 break 90 if has_high_char: 91 buf2 = ctypes.create_unicode_buffer(1024) 92 if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024): # type: ignore[attr-defined,unused-ignore] 93 buf = buf2 94 95 return buf.value 96 97 98def _get_data_dir() -> Path: 99 if os.getenv('CONFIG_NINJA_HOME'): 100 return Path(os.environ['CONFIG_NINJA_HOME']).expanduser() 101 102 if WINDOWS: # pragma: no cover 103 try: 104 from ctypes import ( # type: ignore[attr-defined,unused-ignore] 105 windll, # pyright: ignore # noqa: F401 106 ) 107 108 base_dir = Path(_get_win_folder_with_ctypes('CSIDL_APPDATA')) 109 except ImportError: 110 base_dir = Path(_get_win_folder_from_registry('CSIDL_APPDATA')) 111 112 elif MACOS: # pragma: no cover 113 base_dir = Path('~/Library/Application Support').expanduser() 114 115 else: 116 base_dir = Path(os.getenv('XDG_DATA_HOME', '~/.local/share')).expanduser() 117 118 return base_dir.resolve() / 'config-ninja' 119 120 121def string_to_bool(value: str) -> bool: 122 """Parse a boolean from the given string.""" 123 return value.lower() in {'true', '1', 'y', 'yes'} 124 125 126class _VirtualEnvironment: 127 """Create a virtual environment.""" 128 129 bin: Path 130 path: Path 131 python: Path 132 133 def __init__(self, path: Path) -> None: 134 self.path = path 135 self.bin = path / ('Scripts' if WINDOWS and not MINGW else 'bin') 136 self.python = self.bin / ('python.exe' if WINDOWS else 'python') 137 138 def __repr__(self) -> str: 139 """Define the string representation of the `_VirtualEnvironment` object. 140 141 >>> _VirtualEnvironment(Path('.venv')) 142 _VirtualEnvironment('.venv') 143 """ 144 return f"{self.__class__.__name__}('{self.path}')" 145 146 @staticmethod 147 def _create_with_venv(target: Path) -> None: 148 """Create a virtual environment using the `venv` module.""" 149 import venv 150 151 builder = venv.EnvBuilder(clear=True, with_pip=True, symlinks=False) 152 context = builder.ensure_directories(target) 153 154 if ( # pragma: no cover # windows 155 WINDOWS and hasattr(context, 'env_exec_cmd') and context.env_exe != context.env_exec_cmd 156 ): 157 target = target.resolve() 158 159 builder.create(target) 160 161 @staticmethod 162 def _create_with_virtualenv(target: Path) -> None: 163 """Create a virtual environment using the `virtualenv` module.""" 164 python_version = f'{sys.version_info.major}.{sys.version_info.minor}' 165 bootstrap_url = f'https://bootstrap.pypa.io/virtualenv/{python_version}/virtualenv.pyz' 166 with tempfile.TemporaryDirectory(prefix='config-ninja-installer') as temp_dir: 167 virtualenv_pyz = Path(temp_dir) / 'virtualenv.pyz' 168 request = Request(bootstrap_url, headers={'User-Agent': USER_AGENT}) 169 with contextlib.closing(urllib.request.urlopen(request)) as response: 170 virtualenv_pyz.write_bytes(response.read()) 171 172 # copy `argv` so we can override it and then restore it 173 argv = copy.deepcopy(sys.argv) 174 sys.argv = [str(virtualenv_pyz), '--clear', '--always-copy', str(target)] 175 176 try: 177 runpy.run_path(str(virtualenv_pyz)) 178 finally: 179 sys.argv = argv 180 181 @classmethod 182 def create(cls, target: Path) -> _VirtualEnvironment: 183 """Create a virtual environment at the specified path. 184 185 On some linux distributions (eg: debian), the distribution-provided python installation 186 might not include `ensurepip`, causing the `venv` module to fail when attempting to create a 187 virtual environment. To mitigate this, we use `importlib` to import both `ensurepip` and 188 `venv`; if either fails, we fall back to using `virtualenv` instead. 189 """ 190 try: 191 importlib.import_module('ensurepip') 192 except ImportError: 193 cls._create_with_virtualenv(target) 194 else: 195 cls._create_with_venv(target) 196 197 env = cls(target) 198 199 try: 200 env.pip('install', '--disable-pip-version-check', '--upgrade', 'pip') 201 except subprocess.CalledProcessError as exc: # pragma: no cover 202 sys.stderr.write(exc.stderr.decode('utf-8') + '\n') 203 sys.stderr.write(f'{warning}: Failed to upgrade pip; additional errors may occur.\n') 204 205 return env 206 207 def pip(self, *args: str) -> subprocess.CompletedProcess[bytes]: 208 """Run the 'pip' installation inside the virtual environment.""" 209 return subprocess.run( 210 [str(self.python), '-m', 'pip', *args], # noqa: S603 # is trusted 211 capture_output=True, 212 check=True, 213 ) 214 215 216class _Version: 217 """Model a PEP 440 version string. 218 219 >>> print(_Version('1.0')) 220 1.0 221 222 >>> _Version('0.9') < _Version('1') < _Version('1.0.1') 223 True 224 225 >>> _Version('1.1.0b3') < _Version('1.1.0b4') < _Version('1.1.0') 226 True 227 228 >>> _Version('2.1.0') > _Version('2.0.0') > _Version('2.0.0b1') > _Version('2.0.0a2') 229 True 230 231 >>> _Version('1.0') == '1.0' 232 True 233 234 >>> with pytest.raises(ValueError): 235 ... invalid = _Version('random') 236 """ 237 238 REGEX = re.compile( 239 r'v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?' 240 '(' 241 '[._-]?' 242 r'(?:(stable|beta|b|rc|RC|alpha|a|patch|pl|p)((?:[.-]?\d+)*)?)?' 243 '([.-]?dev)?' 244 ')?' 245 r'(?:\+[^\s]+)?' 246 ) 247 major: int | None 248 minor: int | None 249 patch: int | None 250 pre: str 251 252 raw: str 253 """The original raw version string.""" 254 255 def __init__(self, version: str) -> None: 256 self.raw = version 257 258 match = self.REGEX.match(version) 259 if not match: 260 raise ValueError(f'Invalid version (does not match regex {self.REGEX}): {version}') 261 262 groups = match.groups() 263 self.major, self.minor, self.patch = tuple( 264 None if ver is None else int(ver) for ver in groups[:3] 265 ) 266 self.pre: str = groups[4] 267 268 def __eq__(self, other: Any) -> bool: 269 return self.tuple == _Version(str(other)).tuple 270 271 def __gt__(self, other: _Version) -> bool: 272 if self.tuple[:3] == other.tuple[:3]: 273 if self.pre and other.pre: 274 return self.pre > other.pre 275 return self.pre == '' and other.pre > '' 276 277 return self.tuple[:3] > other.tuple[:3] 278 279 def __lt__(self, other: _Version) -> bool: 280 if self.tuple[:3] == other.tuple[:3]: 281 if self.pre and other.pre: 282 return self.pre < other.pre 283 return self.pre > '' and other.pre == '' 284 return self.tuple[:3] < other.tuple[:3] 285 286 def __repr__(self) -> str: 287 """Define the string representation of the `_Version` object. 288 289 >>> _Version('1.0') 290 _Version('1.0') 291 """ 292 return f"_Version('{self.raw}')" 293 294 def __str__(self) -> str: 295 semver = '.'.join([str(v) for v in self.tuple[:3] if v is not None]) 296 return f'{semver}{self.pre or ""}' 297 298 @property 299 def tuple(self) -> tuple[int | str | None, ...]: 300 """Return the version as a tuple for comparisons.""" 301 version = (self.major, self.minor, self.patch, self.pre) 302 return tuple(v for v in version if v == 0 or v) 303 304 305class Installer: 306 """Install the config-ninja package. 307 308 >>> spec = Installer.Spec(Path('.cn'), version='1.0') 309 >>> installer = Installer(spec) 310 >>> installer.install() 311 _VirtualEnvironment(...) 312 313 If the specified version is not available, a `ValueError` is raised: 314 315 >>> spec = Installer.Spec(Path('.cn'), version='0.9') 316 >>> installer = Installer(spec) 317 >>> with pytest.raises(ValueError): 318 ... installer.install() 319 320 By default, the latest version available from PyPI is installed: 321 322 >>> spec = Installer.Spec(Path('.cn')) 323 >>> installer = Installer(spec) 324 >>> installer.install() 325 _VirtualEnvironment(...) 326 327 Pre-release versions are excluded unless the `pre` argument is passed: 328 329 >>> spec = Installer.Spec(Path('.cn'), pre=True) 330 >>> installer = Installer(spec) 331 >>> installer.install() 332 _VirtualEnvironment(...) 333 """ 334 335 METADATA_URL = 'https://pypi.org/pypi/config-ninja/json' 336 """Retrieve the latest version of config-ninja from this URL.""" 337 338 _allow_pre_releases: bool 339 _extras: str 340 _force: bool 341 _path: Path 342 _version: _Version | None 343 344 @dataclass 345 class Spec: 346 """Specify parameters for the `Installer` class.""" 347 348 path: Path 349 350 extras: str = '' 351 force: bool = False 352 pre: bool = False 353 version: str | None = None 354 355 def __init__(self, spec: Spec) -> None: 356 """Initialize properties on the `Installer` object.""" 357 self._path = spec.path 358 359 self._allow_pre_releases = spec.pre 360 self._extras = spec.extras 361 self._force = spec.force 362 self._version = _Version(spec.version) if spec.version else None 363 364 def _get_releases_from_pypi(self) -> list[_Version]: 365 request = Request(self.METADATA_URL, headers={'User-Agent': USER_AGENT}) 366 367 with contextlib.closing(urllib.request.urlopen(request)) as response: 368 resp_bytes: bytes = response.read() 369 370 metadata: dict[str, Any] = json.loads(resp_bytes.decode('utf-8')) 371 return sorted([_Version(k) for k in metadata['releases'].keys()]) 372 373 def _get_latest_release(self, releases: list[_Version]) -> _Version: 374 for version in reversed(releases): 375 if version.pre and self._allow_pre_releases: 376 return version 377 378 if not version.pre: 379 return version 380 381 raise ValueError( # pragma: no cover 382 'Unable to find a valid release; try installing a pre-release ' 383 "by passing the '--pre' argument" 384 ) 385 386 def _get_version(self) -> _Version: 387 releases = self._get_releases_from_pypi() 388 389 if self._version and self._version not in releases: 390 raise ValueError(f'Unable to find version: {self._version}') 391 392 if not self._version: 393 return self._get_latest_release(releases) 394 395 return self._version 396 397 @property 398 def force(self) -> bool: 399 """If truthy, skip overwrite existing paths.""" 400 return self._force 401 402 def symlink(self, source: Path, check_target: Path, remove: bool = False) -> Path: 403 """Recurse up parent directories until the first 'bin' is found.""" 404 if (bin_dir := check_target / 'bin').is_dir(): 405 if (target := bin_dir / 'config-ninja').exists() and not self._force and not remove: 406 raise FileExistsError(target) 407 408 target.unlink(missing_ok=True) 409 if not remove: 410 os.symlink(source, target) 411 return target 412 413 if ( # pragma: no cover # windows 414 not check_target.parent or not check_target.parent.is_dir() 415 ): 416 raise FileNotFoundError('Could not find directory for symlink') 417 418 return self.symlink(source, check_target.parent, remove) 419 420 def install(self) -> _VirtualEnvironment: 421 """Install the config-ninja package.""" 422 if self._path.exists() and not self._force: 423 raise FileExistsError(f'Path already exists: {self._path}') 424 425 version = self._get_version() 426 env = _VirtualEnvironment.create(self._path) 427 428 args = ['install'] 429 if self._force: 430 args.append('--upgrade') 431 args.append('--force-reinstall') 432 args.append(f'config-ninja{self._extras}=={version}') 433 434 env.pip(*args) 435 436 return env 437 438 @property 439 def path(self) -> Path: 440 """Get the installation path.""" 441 return self._path 442 443 444def _extras_type(value: str) -> str: 445 """Parse the given comma-separated string of package extras. 446 447 >>> _extras_type('appconfig,local') 448 '[appconfig,local]' 449 450 If given 'none', an empty string is returned: 451 452 >>> _extras_type('none') 453 '' 454 455 Invalid extras are removed: 456 457 >>> _extras_type('appconfig,invalid,local') 458 '[appconfig,local]' 459 """ 460 if not value or value == 'none': 461 return '' 462 extras = [extra.strip() for extra in value.split(',') if extra.strip() in PACKAGE_EXTRAS] 463 return f'[{",".join(extras)}]' if extras else '' 464 465 466def _parse_args(argv: tuple[str, ...]) -> argparse.Namespace: 467 parser = argparse.ArgumentParser( 468 prog='install', description='Installs the latest (or given) version of config-ninja' 469 ) 470 parser.add_argument('--version', help='install named version', dest='version') 471 parser.add_argument( 472 '--pre', 473 help='allow pre-release versions to be installed', 474 dest='pre', 475 action='store_true', 476 default=False, 477 ) 478 parser.add_argument( 479 '--uninstall', 480 help='uninstall config-ninja', 481 dest='uninstall', 482 action='store_true', 483 default=False, 484 ) 485 parser.add_argument( 486 '--force', 487 help="respond 'yes' to confirmation prompts; overwrite existing installations", 488 dest='force', 489 action='store_true', 490 default=False, 491 ) 492 parser.add_argument( 493 '--path', 494 default=None, 495 dest='path', 496 action='store', 497 type=Path, 498 help='install config-ninja to this directory', 499 ) 500 parser.add_argument( 501 '--backends', 502 dest='backends', 503 action='store', 504 type=_extras_type, 505 help="comma-separated list of package extras to install, or 'none' to install no backends", 506 ) 507 508 return parser.parse_args(argv) 509 510 511def blue(text: Any) -> str: 512 """Color the given text blue.""" 513 return f'\033[94m{text}\033[0m' 514 515 516def cyan(text: Any) -> str: 517 """Color the given text cyan.""" 518 return f'\033[96m{text}\033[0m' 519 520 521def gray(text: Any) -> str: # pragma: no cover # edge case / windows 522 """Color the given text gray.""" 523 return f'\033[90m{text}\033[0m' 524 525 526def green(text: Any) -> str: 527 """Color the given text green.""" 528 return f'\033[92m{text}\033[0m' 529 530 531def orange(text: Any) -> str: 532 """Color the given text orange.""" 533 return f'\033[33m{text}\033[0m' 534 535 536def red(text: Any) -> str: 537 """Color the given text red.""" 538 return f'\033[91m{text}\033[0m' 539 540 541def yellow(text: Any) -> str: 542 """Color the given text yellow.""" 543 return f'\033[93m{text}\033[0m' 544 545 546warning = yellow('WARNING') 547failure = red('FAILURE') 548prompts = blue('PROMPTS') 549success = green('SUCCESS') 550 551 552def _maybe_create_symlink(installer: Installer, env: _VirtualEnvironment) -> None: 553 if env.bin in [ # pragma: no cover # edge case for installation to e.g. /usr/local 554 Path(p) for p in os.getenv('PATH', '').split(os.pathsep) 555 ]: 556 # we're already on the PATH; no need to symlink 557 return 558 559 if WINDOWS and not MINGW: # pragma: no cover 560 sys.stdout.write( 561 f'{warning}: In order to run the {blue("config-ninja")} command, add ' 562 f'the following line to {gray(os.getenv("USERPROFILE"))}:\n' 563 ) 564 rhs = cyan(f'";{env.bin}"') 565 sys.stdout.write(f'{green("$Env:Path")} += {rhs}') 566 return 567 568 try: 569 symlink = installer.symlink(env.bin / 'config-ninja', env.path.parent) 570 except FileExistsError as exc: 571 sys.stderr.write(f'{warning}: Already exists: {cyan(exc.args[0])}\n') 572 sys.stderr.write(f'Pass the {blue("--force")} argument to clobber it\n') 573 sys.exit(RC_PATH_EXISTS) 574 except (FileNotFoundError, PermissionError): 575 sys.stderr.write(f'{warning}: Failed to create symlink\n') 576 shell = os.getenv('SHELL', '').split(os.sep)[-1] 577 your_dotfile = ( 578 cyan(Path.home() / f'.{shell}rc') if shell else f"your shell's {cyan('~/.*rc')} file" 579 ) 580 sys.stderr.write( 581 f'In order to run the {blue("config-ninja")} command, add the following ' 582 + f'line to {your_dotfile}:\n' 583 ) 584 path = orange(f'"{env.bin}:$PATH"') 585 sys.stderr.write(f'{blue("export")} PATH={path}') 586 return 587 588 sys.stdout.write(f'A symlink was created at {green(symlink)}\n') 589 590 591def _do_install(installer: Installer) -> None: 592 sys.stdout.write(f'🥷 Installing {blue("config-ninja")} to path {cyan(installer.path)}...\n') 593 sys.stdout.flush() 594 595 try: 596 env = installer.install() 597 except FileExistsError as exc: 598 sys.stderr.write(f'{failure}: {exc}\n') 599 sys.stdout.write( 600 f"Pass the {blue('--force')} argument to clobber it or " 601 f"{blue('--uninstall')} to remove it.\n" 602 ) 603 sys.exit(RC_PATH_EXISTS) 604 605 sys.stdout.write(f'{success}: Installation to virtual environment complete ✅\n') 606 607 _maybe_create_symlink(installer, env) 608 609 610def _do_uninstall(installer: Installer) -> None: 611 if not installer.force: 612 prompt = f"Uninstall {blue('config-ninja')} from {cyan(installer.path)}? [y/N]: " 613 if Path('/dev/tty').exists(): 614 sys.stdout.write(prompt) 615 sys.stdout.flush() 616 with open('/dev/tty', encoding='utf-8') as tty: 617 uninstall = tty.readline().strip() 618 else: # pragma: no cover # windows 619 uninstall = input(prompt) 620 621 if not uninstall.lower().startswith('y'): 622 sys.stderr.write(f'{failure}: Aborted uninstallation ❌\n') 623 sys.exit(RC_PATH_EXISTS) 624 625 sys.stdout.write('...\n') 626 shutil.rmtree(installer.path) 627 sys.stdout.write( 628 f"{success}: Uninstalled {blue('config-ninja')} from path {cyan(installer.path)} ✅\n" 629 ) 630 if not WINDOWS or MINGW: # pragma: no cover # windows 631 installer.symlink( 632 installer.path / 'bin' / 'config-ninja', installer.path.parent, remove=True 633 ) 634 635 636def main(*argv: str) -> None: 637 """Install the `config-ninja` package to a virtual environment.""" 638 args = _parse_args(argv) 639 install_path: Path = args.path or _get_data_dir() 640 641 spec = Installer.Spec( 642 install_path, 643 version=args.version or os.getenv('CONFIG_NINJA_VERSION'), 644 force=args.force or string_to_bool(os.getenv('CONFIG_NINJA_FORCE', 'false')), 645 pre=args.pre or string_to_bool(os.getenv('CONFIG_NINJA_PRE', 'false')), 646 extras=args.backends or os.getenv('CONFIG_NINJA_BACKENDS', '[all]'), 647 ) 648 649 installer = Installer(spec) 650 if args.uninstall: 651 if not installer.path.is_dir(): 652 sys.stdout.write(f'{warning}: Path does not exist: {cyan(installer.path)}\n') 653 return 654 _do_uninstall(installer) 655 return 656 657 _do_install(installer) 658 659 660if __name__ == '__main__': # pragma: no cover 661 main(*sys.argv[1:])
122def string_to_bool(value: str) -> bool: 123 """Parse a boolean from the given string.""" 124 return value.lower() in {'true', '1', 'y', 'yes'}
Parse a boolean from the given string.
306class Installer: 307 """Install the config-ninja package. 308 309 >>> spec = Installer.Spec(Path('.cn'), version='1.0') 310 >>> installer = Installer(spec) 311 >>> installer.install() 312 _VirtualEnvironment(...) 313 314 If the specified version is not available, a `ValueError` is raised: 315 316 >>> spec = Installer.Spec(Path('.cn'), version='0.9') 317 >>> installer = Installer(spec) 318 >>> with pytest.raises(ValueError): 319 ... installer.install() 320 321 By default, the latest version available from PyPI is installed: 322 323 >>> spec = Installer.Spec(Path('.cn')) 324 >>> installer = Installer(spec) 325 >>> installer.install() 326 _VirtualEnvironment(...) 327 328 Pre-release versions are excluded unless the `pre` argument is passed: 329 330 >>> spec = Installer.Spec(Path('.cn'), pre=True) 331 >>> installer = Installer(spec) 332 >>> installer.install() 333 _VirtualEnvironment(...) 334 """ 335 336 METADATA_URL = 'https://pypi.org/pypi/config-ninja/json' 337 """Retrieve the latest version of config-ninja from this URL.""" 338 339 _allow_pre_releases: bool 340 _extras: str 341 _force: bool 342 _path: Path 343 _version: _Version | None 344 345 @dataclass 346 class Spec: 347 """Specify parameters for the `Installer` class.""" 348 349 path: Path 350 351 extras: str = '' 352 force: bool = False 353 pre: bool = False 354 version: str | None = None 355 356 def __init__(self, spec: Spec) -> None: 357 """Initialize properties on the `Installer` object.""" 358 self._path = spec.path 359 360 self._allow_pre_releases = spec.pre 361 self._extras = spec.extras 362 self._force = spec.force 363 self._version = _Version(spec.version) if spec.version else None 364 365 def _get_releases_from_pypi(self) -> list[_Version]: 366 request = Request(self.METADATA_URL, headers={'User-Agent': USER_AGENT}) 367 368 with contextlib.closing(urllib.request.urlopen(request)) as response: 369 resp_bytes: bytes = response.read() 370 371 metadata: dict[str, Any] = json.loads(resp_bytes.decode('utf-8')) 372 return sorted([_Version(k) for k in metadata['releases'].keys()]) 373 374 def _get_latest_release(self, releases: list[_Version]) -> _Version: 375 for version in reversed(releases): 376 if version.pre and self._allow_pre_releases: 377 return version 378 379 if not version.pre: 380 return version 381 382 raise ValueError( # pragma: no cover 383 'Unable to find a valid release; try installing a pre-release ' 384 "by passing the '--pre' argument" 385 ) 386 387 def _get_version(self) -> _Version: 388 releases = self._get_releases_from_pypi() 389 390 if self._version and self._version not in releases: 391 raise ValueError(f'Unable to find version: {self._version}') 392 393 if not self._version: 394 return self._get_latest_release(releases) 395 396 return self._version 397 398 @property 399 def force(self) -> bool: 400 """If truthy, skip overwrite existing paths.""" 401 return self._force 402 403 def symlink(self, source: Path, check_target: Path, remove: bool = False) -> Path: 404 """Recurse up parent directories until the first 'bin' is found.""" 405 if (bin_dir := check_target / 'bin').is_dir(): 406 if (target := bin_dir / 'config-ninja').exists() and not self._force and not remove: 407 raise FileExistsError(target) 408 409 target.unlink(missing_ok=True) 410 if not remove: 411 os.symlink(source, target) 412 return target 413 414 if ( # pragma: no cover # windows 415 not check_target.parent or not check_target.parent.is_dir() 416 ): 417 raise FileNotFoundError('Could not find directory for symlink') 418 419 return self.symlink(source, check_target.parent, remove) 420 421 def install(self) -> _VirtualEnvironment: 422 """Install the config-ninja package.""" 423 if self._path.exists() and not self._force: 424 raise FileExistsError(f'Path already exists: {self._path}') 425 426 version = self._get_version() 427 env = _VirtualEnvironment.create(self._path) 428 429 args = ['install'] 430 if self._force: 431 args.append('--upgrade') 432 args.append('--force-reinstall') 433 args.append(f'config-ninja{self._extras}=={version}') 434 435 env.pip(*args) 436 437 return env 438 439 @property 440 def path(self) -> Path: 441 """Get the installation path.""" 442 return self._path
Install the config-ninja package.
>>> spec = Installer.Spec(Path('.cn'), version='1.0')
>>> installer = Installer(spec)
>>> installer.install()
_VirtualEnvironment(...)
If the specified version is not available, a ValueError
is raised:
>>> spec = Installer.Spec(Path('.cn'), version='0.9')
>>> installer = Installer(spec)
>>> with pytest.raises(ValueError):
... installer.install()
By default, the latest version available from PyPI is installed:
>>> spec = Installer.Spec(Path('.cn'))
>>> installer = Installer(spec)
>>> installer.install()
_VirtualEnvironment(...)
Pre-release versions are excluded unless the pre
argument is passed:
>>> spec = Installer.Spec(Path('.cn'), pre=True)
>>> installer = Installer(spec)
>>> installer.install()
_VirtualEnvironment(...)
356 def __init__(self, spec: Spec) -> None: 357 """Initialize properties on the `Installer` object.""" 358 self._path = spec.path 359 360 self._allow_pre_releases = spec.pre 361 self._extras = spec.extras 362 self._force = spec.force 363 self._version = _Version(spec.version) if spec.version else None
Initialize properties on the Installer
object.
Retrieve the latest version of config-ninja from this URL.
398 @property 399 def force(self) -> bool: 400 """If truthy, skip overwrite existing paths.""" 401 return self._force
If truthy, skip overwrite existing paths.
403 def symlink(self, source: Path, check_target: Path, remove: bool = False) -> Path: 404 """Recurse up parent directories until the first 'bin' is found.""" 405 if (bin_dir := check_target / 'bin').is_dir(): 406 if (target := bin_dir / 'config-ninja').exists() and not self._force and not remove: 407 raise FileExistsError(target) 408 409 target.unlink(missing_ok=True) 410 if not remove: 411 os.symlink(source, target) 412 return target 413 414 if ( # pragma: no cover # windows 415 not check_target.parent or not check_target.parent.is_dir() 416 ): 417 raise FileNotFoundError('Could not find directory for symlink') 418 419 return self.symlink(source, check_target.parent, remove)
Recurse up parent directories until the first 'bin' is found.
421 def install(self) -> _VirtualEnvironment: 422 """Install the config-ninja package.""" 423 if self._path.exists() and not self._force: 424 raise FileExistsError(f'Path already exists: {self._path}') 425 426 version = self._get_version() 427 env = _VirtualEnvironment.create(self._path) 428 429 args = ['install'] 430 if self._force: 431 args.append('--upgrade') 432 args.append('--force-reinstall') 433 args.append(f'config-ninja{self._extras}=={version}') 434 435 env.pip(*args) 436 437 return env
Install the config-ninja package.
439 @property 440 def path(self) -> Path: 441 """Get the installation path.""" 442 return self._path
Get the installation path.
345 @dataclass 346 class Spec: 347 """Specify parameters for the `Installer` class.""" 348 349 path: Path 350 351 extras: str = '' 352 force: bool = False 353 pre: bool = False 354 version: str | None = None
Specify parameters for the Installer
class.
512def blue(text: Any) -> str: 513 """Color the given text blue.""" 514 return f'\033[94m{text}\033[0m'
Color the given text blue.
517def cyan(text: Any) -> str: 518 """Color the given text cyan.""" 519 return f'\033[96m{text}\033[0m'
Color the given text cyan.
522def gray(text: Any) -> str: # pragma: no cover # edge case / windows 523 """Color the given text gray.""" 524 return f'\033[90m{text}\033[0m'
Color the given text gray.
527def green(text: Any) -> str: 528 """Color the given text green.""" 529 return f'\033[92m{text}\033[0m'
Color the given text green.
532def orange(text: Any) -> str: 533 """Color the given text orange.""" 534 return f'\033[33m{text}\033[0m'
Color the given text orange.
537def red(text: Any) -> str: 538 """Color the given text red.""" 539 return f'\033[91m{text}\033[0m'
Color the given text red.
542def yellow(text: Any) -> str: 543 """Color the given text yellow.""" 544 return f'\033[93m{text}\033[0m'
Color the given text yellow.
637def main(*argv: str) -> None: 638 """Install the `config-ninja` package to a virtual environment.""" 639 args = _parse_args(argv) 640 install_path: Path = args.path or _get_data_dir() 641 642 spec = Installer.Spec( 643 install_path, 644 version=args.version or os.getenv('CONFIG_NINJA_VERSION'), 645 force=args.force or string_to_bool(os.getenv('CONFIG_NINJA_FORCE', 'false')), 646 pre=args.pre or string_to_bool(os.getenv('CONFIG_NINJA_PRE', 'false')), 647 extras=args.backends or os.getenv('CONFIG_NINJA_BACKENDS', '[all]'), 648 ) 649 650 installer = Installer(spec) 651 if args.uninstall: 652 if not installer.path.is_dir(): 653 sys.stdout.write(f'{warning}: Path does not exist: {cyan(installer.path)}\n') 654 return 655 _do_uninstall(installer) 656 return 657 658 _do_install(installer)
Install the config-ninja
package to a virtual environment.