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, 9): # noqa: UP036 16 sys.stdout.write('config-ninja installer requires Python 3.9 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 # noqa: PLC0415 # 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 # noqa: PLC0415 # 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] # noqa: PLC0415 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 # noqa: PLC0415 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( # noqa: S603 210 [str(self.python), '-m', 'pip', *args], # 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 >>> vset = {_Version('1.0'), _Version('2.0'), _Version('3.0')} 235 >>> _Version('1.0') in vset 236 True 237 238 >>> with pytest.raises(ValueError): 239 ... invalid = _Version('random') 240 """ 241 242 REGEX = re.compile( 243 r'v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?' 244 '(' 245 '[._-]?' 246 r'(?:(stable|beta|b|rc|RC|alpha|a|patch|pl|p)((?:[.-]?\d+)*)?)?' 247 '([.-]?dev)?' 248 ')?' 249 r'(?:\+[^\s]+)?' 250 ) 251 major: int | None 252 minor: int | None 253 patch: int | None 254 pre: str 255 256 raw: str 257 """The original raw version string.""" 258 259 def __init__(self, version: str) -> None: 260 self.raw = version 261 262 match = self.REGEX.match(version) 263 if not match: 264 raise ValueError(f'Invalid version (does not match regex {self.REGEX}): {version}') 265 266 groups = match.groups() 267 self.major, self.minor, self.patch = tuple(None if ver is None else int(ver) for ver in groups[:3]) 268 self.pre: str = groups[4] 269 270 def __eq__(self, other: Any) -> bool: 271 return self.tuple == _Version(str(other)).tuple 272 273 def __hash__(self) -> int: 274 return hash(self.tuple) 275 276 def __gt__(self, other: _Version) -> bool: 277 if self.tuple[:3] == other.tuple[:3]: 278 if self.pre and other.pre: 279 return self.pre > other.pre 280 return self.pre == '' and other.pre > '' 281 282 return self.tuple[:3] > other.tuple[:3] 283 284 def __lt__(self, other: _Version) -> bool: 285 if self.tuple[:3] == other.tuple[:3]: 286 if self.pre and other.pre: 287 return self.pre < other.pre 288 return self.pre > '' and other.pre == '' 289 return self.tuple[:3] < other.tuple[:3] 290 291 def __repr__(self) -> str: 292 """Define the string representation of the `_Version` object. 293 294 >>> _Version('1.0') 295 _Version('1.0') 296 """ 297 return f"_Version('{self.raw}')" 298 299 def __str__(self) -> str: 300 semver = '.'.join([str(v) for v in self.tuple[:3] if v is not None]) 301 return f'{semver}{self.pre or ""}' 302 303 @property 304 def tuple(self) -> tuple[int | str | None, ...]: 305 """Return the version as a tuple for comparisons.""" 306 version = (self.major, self.minor, self.patch, self.pre) 307 return tuple(v for v in version if v == 0 or v) 308 309 310class Installer: 311 """Install the config-ninja package. 312 313 >>> spec = Installer.Spec(Path('.cn'), version='1.0') 314 >>> installer = Installer(spec) 315 >>> installer.install() 316 _VirtualEnvironment(...) 317 318 If the specified version is not available, a `ValueError` is raised: 319 320 >>> spec = Installer.Spec(Path('.cn'), version='0.9') 321 >>> installer = Installer(spec) 322 >>> with pytest.raises(ValueError): 323 ... installer.install() 324 325 By default, the latest version available from PyPI is installed: 326 327 >>> spec = Installer.Spec(Path('.cn')) 328 >>> installer = Installer(spec) 329 >>> installer.install() 330 _VirtualEnvironment(...) 331 332 Pre-release versions are excluded unless the `pre` argument is passed: 333 334 >>> spec = Installer.Spec(Path('.cn'), pre=True) 335 >>> installer = Installer(spec) 336 >>> installer.install() 337 _VirtualEnvironment(...) 338 """ 339 340 METADATA_URL = 'https://pypi.org/pypi/config-ninja/json' 341 """Retrieve the latest version of config-ninja from this URL.""" 342 343 _allow_pre_releases: bool 344 _extras: str 345 _force: bool 346 _path: Path 347 _version: _Version | None 348 349 @dataclass 350 class Spec: 351 """Specify parameters for the `Installer` class.""" 352 353 path: Path 354 355 extras: str = '' 356 force: bool = False 357 pre: bool = False 358 version: str | None = None 359 360 def __init__(self, spec: Spec) -> None: 361 """Initialize properties on the `Installer` object.""" 362 self._path = spec.path 363 364 self._allow_pre_releases = spec.pre 365 self._extras = spec.extras 366 self._force = spec.force 367 self._version = _Version(spec.version) if spec.version else None 368 369 def _get_releases_from_pypi(self) -> list[_Version]: 370 request = Request(self.METADATA_URL, headers={'User-Agent': USER_AGENT}) 371 372 with contextlib.closing(urllib.request.urlopen(request)) as response: 373 resp_bytes: bytes = response.read() 374 375 metadata: dict[str, Any] = json.loads(resp_bytes.decode('utf-8')) 376 return sorted([_Version(k) for k in metadata['releases'].keys()]) 377 378 def _get_latest_release(self, releases: list[_Version]) -> _Version: 379 for version in reversed(releases): 380 if version.pre and self._allow_pre_releases: 381 return version 382 383 if not version.pre: 384 return version 385 386 raise ValueError( # pragma: no cover 387 "Unable to find a valid release; try installing a pre-release by passing the '--pre' argument" 388 ) 389 390 def _get_version(self) -> _Version: 391 releases = self._get_releases_from_pypi() 392 393 if self._version and self._version not in releases: 394 raise ValueError(f'Unable to find version: {self._version}') 395 396 if not self._version: 397 return self._get_latest_release(releases) 398 399 return self._version 400 401 @property 402 def force(self) -> bool: 403 """If truthy, skip overwrite existing paths.""" 404 return self._force 405 406 def symlink(self, source: Path, check_target: Path, remove: bool = False) -> Path: 407 """Recurse up parent directories until the first 'bin' is found.""" 408 if (bin_dir := check_target / 'bin').is_dir(): 409 if (target := bin_dir / 'config-ninja').exists() and not self._force and not remove: 410 raise FileExistsError(target) 411 412 target.unlink(missing_ok=True) 413 if not remove: 414 os.symlink(source, target) 415 return target 416 417 if ( # pragma: no cover # windows 418 not check_target.parent or not check_target.parent.is_dir() 419 ): 420 raise FileNotFoundError('Could not find directory for symlink') 421 422 return self.symlink(source, check_target.parent, remove) 423 424 def install(self) -> _VirtualEnvironment: 425 """Install the config-ninja package.""" 426 if self._path.exists() and not self._force: 427 raise FileExistsError(f'Path already exists: {self._path}') 428 429 version = self._get_version() 430 env = _VirtualEnvironment.create(self._path) 431 432 args = ['install'] 433 if self._force: 434 args.append('--upgrade') 435 args.append('--force-reinstall') 436 args.append(f'config-ninja{self._extras}=={version}') 437 438 env.pip(*args) 439 440 return env 441 442 @property 443 def path(self) -> Path: 444 """Get the installation path.""" 445 return self._path 446 447 448def _extras_type(value: str) -> str: 449 """Parse the given comma-separated string of package extras. 450 451 >>> _extras_type('appconfig,local') 452 '[appconfig,local]' 453 454 If given 'none', an empty string is returned: 455 456 >>> _extras_type('none') 457 '' 458 459 Invalid extras are removed: 460 461 >>> _extras_type('appconfig,invalid,local') 462 '[appconfig,local]' 463 """ 464 if not value or value == 'none': 465 return '' 466 extras = [extra.strip() for extra in value.split(',') if extra.strip() in PACKAGE_EXTRAS] 467 return f'[{",".join(extras)}]' if extras else '' 468 469 470def _parse_args(argv: tuple[str, ...]) -> argparse.Namespace: 471 parser = argparse.ArgumentParser( 472 prog='install', description='Installs the latest (or given) version of config-ninja' 473 ) 474 parser.add_argument('--version', help='install named version', dest='version') 475 parser.add_argument( 476 '--pre', 477 help='allow pre-release versions to be installed', 478 dest='pre', 479 action='store_true', 480 default=False, 481 ) 482 parser.add_argument( 483 '--uninstall', 484 help='uninstall config-ninja', 485 dest='uninstall', 486 action='store_true', 487 default=False, 488 ) 489 parser.add_argument( 490 '--force', 491 help="respond 'yes' to confirmation prompts; overwrite existing installations", 492 dest='force', 493 action='store_true', 494 default=False, 495 ) 496 parser.add_argument( 497 '--path', 498 default=None, 499 dest='path', 500 action='store', 501 type=Path, 502 help='install config-ninja to this directory', 503 ) 504 parser.add_argument( 505 '--backends', 506 dest='backends', 507 action='store', 508 type=_extras_type, 509 help="comma-separated list of package extras to install, or 'none' to install no backends", 510 ) 511 512 return parser.parse_args(argv) 513 514 515def blue(text: Any) -> str: 516 """Color the given text blue.""" 517 return f'\033[94m{text}\033[0m' 518 519 520def cyan(text: Any) -> str: 521 """Color the given text cyan.""" 522 return f'\033[96m{text}\033[0m' 523 524 525def gray(text: Any) -> str: # pragma: no cover # edge case / windows 526 """Color the given text gray.""" 527 return f'\033[90m{text}\033[0m' 528 529 530def green(text: Any) -> str: 531 """Color the given text green.""" 532 return f'\033[92m{text}\033[0m' 533 534 535def orange(text: Any) -> str: 536 """Color the given text orange.""" 537 return f'\033[33m{text}\033[0m' 538 539 540def red(text: Any) -> str: 541 """Color the given text red.""" 542 return f'\033[91m{text}\033[0m' 543 544 545def yellow(text: Any) -> str: 546 """Color the given text yellow.""" 547 return f'\033[93m{text}\033[0m' 548 549 550warning = yellow('WARNING') 551failure = red('FAILURE') 552prompts = blue('PROMPTS') 553success = green('SUCCESS') 554 555 556def _maybe_create_symlink(installer: Installer, env: _VirtualEnvironment) -> None: 557 if env.bin in [ # pragma: no cover # edge case for installation to e.g. /usr/local 558 Path(p) for p in os.getenv('PATH', '').split(os.pathsep) 559 ]: 560 # we're already on the PATH; no need to symlink 561 return 562 563 if WINDOWS and not MINGW: # pragma: no cover 564 sys.stdout.write( 565 f'{warning}: In order to run the {blue("config-ninja")} command, add ' 566 f'the following line to {gray(os.getenv("USERPROFILE"))}:\n' 567 ) 568 rhs = cyan(f'";{env.bin}"') 569 sys.stdout.write(f'{green("$Env:Path")} += {rhs}') 570 return 571 572 try: 573 symlink = installer.symlink(env.bin / 'config-ninja', env.path.parent) 574 except FileExistsError as exc: 575 sys.stderr.write(f'{warning}: Already exists: {cyan(exc.args[0])}\n') 576 sys.stderr.write(f'Pass the {blue("--force")} argument to clobber it\n') 577 sys.exit(RC_PATH_EXISTS) 578 except (FileNotFoundError, PermissionError): 579 sys.stderr.write(f'{warning}: Failed to create symlink\n') 580 shell = os.getenv('SHELL', '').split(os.sep)[-1] 581 your_dotfile = cyan(Path.home() / f'.{shell}rc') if shell else f"your shell's {cyan('~/.*rc')} file" 582 sys.stderr.write( 583 f'In order to run the {blue("config-ninja")} command, add the following ' + f'line to {your_dotfile}:\n' 584 ) 585 path = orange(f'"{env.bin}:$PATH"') 586 sys.stderr.write(f'{blue("export")} PATH={path}') 587 return 588 589 sys.stdout.write(f'A symlink was created at {green(symlink)}\n') 590 591 592def _do_install(installer: Installer) -> None: 593 sys.stdout.write(f'🥷 Installing {blue("config-ninja")} to path {cyan(installer.path)}...\n') 594 sys.stdout.flush() 595 596 try: 597 env = installer.install() 598 except FileExistsError as exc: 599 sys.stderr.write(f'{failure}: {exc}\n') 600 sys.stdout.write(f'Pass the {blue("--force")} argument to clobber it or {blue("--uninstall")} to remove it.\n') 601 sys.exit(RC_PATH_EXISTS) 602 603 sys.stdout.write(f'{success}: Installation to virtual environment complete ✅\n') 604 605 _maybe_create_symlink(installer, env) 606 607 608def _do_uninstall(installer: Installer) -> None: 609 if not installer.force: 610 prompt = f'Uninstall {blue("config-ninja")} from {cyan(installer.path)}? [y/N]: ' 611 if Path('/dev/tty').exists(): 612 sys.stdout.write(prompt) 613 sys.stdout.flush() 614 with open('/dev/tty', encoding='utf-8') as tty: 615 uninstall = tty.readline().strip() 616 else: # pragma: no cover # windows 617 uninstall = input(prompt) 618 619 if not uninstall.lower().startswith('y'): # pragma: no cover 620 sys.stderr.write(f'{failure}: Aborted uninstallation ❌\n') 621 sys.exit(RC_PATH_EXISTS) 622 623 sys.stdout.write('...\n') 624 shutil.rmtree(installer.path) 625 sys.stdout.write(f'{success}: Uninstalled {blue("config-ninja")} from path {cyan(installer.path)} ✅\n') 626 if not WINDOWS or MINGW: # pragma: no cover # windows 627 installer.symlink(installer.path / 'bin' / 'config-ninja', installer.path.parent, remove=True) 628 629 630def main(*argv: str) -> None: 631 """Install the `config-ninja` package to a virtual environment.""" 632 args = _parse_args(argv) 633 install_path: Path = args.path or _get_data_dir() 634 635 spec = Installer.Spec( 636 install_path, 637 version=args.version or os.getenv('CONFIG_NINJA_VERSION'), 638 force=args.force or string_to_bool(os.getenv('CONFIG_NINJA_FORCE', 'false')), 639 pre=args.pre or string_to_bool(os.getenv('CONFIG_NINJA_PRE', 'false')), 640 extras=args.backends or os.getenv('CONFIG_NINJA_BACKENDS', '[all]'), 641 ) 642 643 installer = Installer(spec) 644 if args.uninstall: 645 if not installer.path.is_dir(): 646 sys.stdout.write(f'{warning}: Path does not exist: {cyan(installer.path)}\n') 647 return 648 _do_uninstall(installer) 649 return 650 651 _do_install(installer) 652 653 654if __name__ == '__main__': # pragma: no cover 655 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.
311class Installer: 312 """Install the config-ninja package. 313 314 >>> spec = Installer.Spec(Path('.cn'), version='1.0') 315 >>> installer = Installer(spec) 316 >>> installer.install() 317 _VirtualEnvironment(...) 318 319 If the specified version is not available, a `ValueError` is raised: 320 321 >>> spec = Installer.Spec(Path('.cn'), version='0.9') 322 >>> installer = Installer(spec) 323 >>> with pytest.raises(ValueError): 324 ... installer.install() 325 326 By default, the latest version available from PyPI is installed: 327 328 >>> spec = Installer.Spec(Path('.cn')) 329 >>> installer = Installer(spec) 330 >>> installer.install() 331 _VirtualEnvironment(...) 332 333 Pre-release versions are excluded unless the `pre` argument is passed: 334 335 >>> spec = Installer.Spec(Path('.cn'), pre=True) 336 >>> installer = Installer(spec) 337 >>> installer.install() 338 _VirtualEnvironment(...) 339 """ 340 341 METADATA_URL = 'https://pypi.org/pypi/config-ninja/json' 342 """Retrieve the latest version of config-ninja from this URL.""" 343 344 _allow_pre_releases: bool 345 _extras: str 346 _force: bool 347 _path: Path 348 _version: _Version | None 349 350 @dataclass 351 class Spec: 352 """Specify parameters for the `Installer` class.""" 353 354 path: Path 355 356 extras: str = '' 357 force: bool = False 358 pre: bool = False 359 version: str | None = None 360 361 def __init__(self, spec: Spec) -> None: 362 """Initialize properties on the `Installer` object.""" 363 self._path = spec.path 364 365 self._allow_pre_releases = spec.pre 366 self._extras = spec.extras 367 self._force = spec.force 368 self._version = _Version(spec.version) if spec.version else None 369 370 def _get_releases_from_pypi(self) -> list[_Version]: 371 request = Request(self.METADATA_URL, headers={'User-Agent': USER_AGENT}) 372 373 with contextlib.closing(urllib.request.urlopen(request)) as response: 374 resp_bytes: bytes = response.read() 375 376 metadata: dict[str, Any] = json.loads(resp_bytes.decode('utf-8')) 377 return sorted([_Version(k) for k in metadata['releases'].keys()]) 378 379 def _get_latest_release(self, releases: list[_Version]) -> _Version: 380 for version in reversed(releases): 381 if version.pre and self._allow_pre_releases: 382 return version 383 384 if not version.pre: 385 return version 386 387 raise ValueError( # pragma: no cover 388 "Unable to find a valid release; try installing a pre-release by passing the '--pre' argument" 389 ) 390 391 def _get_version(self) -> _Version: 392 releases = self._get_releases_from_pypi() 393 394 if self._version and self._version not in releases: 395 raise ValueError(f'Unable to find version: {self._version}') 396 397 if not self._version: 398 return self._get_latest_release(releases) 399 400 return self._version 401 402 @property 403 def force(self) -> bool: 404 """If truthy, skip overwrite existing paths.""" 405 return self._force 406 407 def symlink(self, source: Path, check_target: Path, remove: bool = False) -> Path: 408 """Recurse up parent directories until the first 'bin' is found.""" 409 if (bin_dir := check_target / 'bin').is_dir(): 410 if (target := bin_dir / 'config-ninja').exists() and not self._force and not remove: 411 raise FileExistsError(target) 412 413 target.unlink(missing_ok=True) 414 if not remove: 415 os.symlink(source, target) 416 return target 417 418 if ( # pragma: no cover # windows 419 not check_target.parent or not check_target.parent.is_dir() 420 ): 421 raise FileNotFoundError('Could not find directory for symlink') 422 423 return self.symlink(source, check_target.parent, remove) 424 425 def install(self) -> _VirtualEnvironment: 426 """Install the config-ninja package.""" 427 if self._path.exists() and not self._force: 428 raise FileExistsError(f'Path already exists: {self._path}') 429 430 version = self._get_version() 431 env = _VirtualEnvironment.create(self._path) 432 433 args = ['install'] 434 if self._force: 435 args.append('--upgrade') 436 args.append('--force-reinstall') 437 args.append(f'config-ninja{self._extras}=={version}') 438 439 env.pip(*args) 440 441 return env 442 443 @property 444 def path(self) -> Path: 445 """Get the installation path.""" 446 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(...)
361 def __init__(self, spec: Spec) -> None: 362 """Initialize properties on the `Installer` object.""" 363 self._path = spec.path 364 365 self._allow_pre_releases = spec.pre 366 self._extras = spec.extras 367 self._force = spec.force 368 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.
402 @property 403 def force(self) -> bool: 404 """If truthy, skip overwrite existing paths.""" 405 return self._force
If truthy, skip overwrite existing paths.
407 def symlink(self, source: Path, check_target: Path, remove: bool = False) -> Path: 408 """Recurse up parent directories until the first 'bin' is found.""" 409 if (bin_dir := check_target / 'bin').is_dir(): 410 if (target := bin_dir / 'config-ninja').exists() and not self._force and not remove: 411 raise FileExistsError(target) 412 413 target.unlink(missing_ok=True) 414 if not remove: 415 os.symlink(source, target) 416 return target 417 418 if ( # pragma: no cover # windows 419 not check_target.parent or not check_target.parent.is_dir() 420 ): 421 raise FileNotFoundError('Could not find directory for symlink') 422 423 return self.symlink(source, check_target.parent, remove)
Recurse up parent directories until the first 'bin' is found.
425 def install(self) -> _VirtualEnvironment: 426 """Install the config-ninja package.""" 427 if self._path.exists() and not self._force: 428 raise FileExistsError(f'Path already exists: {self._path}') 429 430 version = self._get_version() 431 env = _VirtualEnvironment.create(self._path) 432 433 args = ['install'] 434 if self._force: 435 args.append('--upgrade') 436 args.append('--force-reinstall') 437 args.append(f'config-ninja{self._extras}=={version}') 438 439 env.pip(*args) 440 441 return env
Install the config-ninja package.
443 @property 444 def path(self) -> Path: 445 """Get the installation path.""" 446 return self._path
Get the installation path.
350 @dataclass 351 class Spec: 352 """Specify parameters for the `Installer` class.""" 353 354 path: Path 355 356 extras: str = '' 357 force: bool = False 358 pre: bool = False 359 version: str | None = None
Specify parameters for the Installer
class.
516def blue(text: Any) -> str: 517 """Color the given text blue.""" 518 return f'\033[94m{text}\033[0m'
Color the given text blue.
521def cyan(text: Any) -> str: 522 """Color the given text cyan.""" 523 return f'\033[96m{text}\033[0m'
Color the given text cyan.
526def gray(text: Any) -> str: # pragma: no cover # edge case / windows 527 """Color the given text gray.""" 528 return f'\033[90m{text}\033[0m'
Color the given text gray.
531def green(text: Any) -> str: 532 """Color the given text green.""" 533 return f'\033[92m{text}\033[0m'
Color the given text green.
536def orange(text: Any) -> str: 537 """Color the given text orange.""" 538 return f'\033[33m{text}\033[0m'
Color the given text orange.
541def red(text: Any) -> str: 542 """Color the given text red.""" 543 return f'\033[91m{text}\033[0m'
Color the given text red.
546def yellow(text: Any) -> str: 547 """Color the given text yellow.""" 548 return f'\033[93m{text}\033[0m'
Color the given text yellow.
631def main(*argv: str) -> None: 632 """Install the `config-ninja` package to a virtual environment.""" 633 args = _parse_args(argv) 634 install_path: Path = args.path or _get_data_dir() 635 636 spec = Installer.Spec( 637 install_path, 638 version=args.version or os.getenv('CONFIG_NINJA_VERSION'), 639 force=args.force or string_to_bool(os.getenv('CONFIG_NINJA_FORCE', 'false')), 640 pre=args.pre or string_to_bool(os.getenv('CONFIG_NINJA_PRE', 'false')), 641 extras=args.backends or os.getenv('CONFIG_NINJA_BACKENDS', '[all]'), 642 ) 643 644 installer = Installer(spec) 645 if args.uninstall: 646 if not installer.path.is_dir(): 647 sys.stdout.write(f'{warning}: Path does not exist: {cyan(installer.path)}\n') 648 return 649 _do_uninstall(installer) 650 return 651 652 _do_install(installer)
Install the config-ninja
package to a virtual environment.