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

class Installer:
304class Installer:
305    """Install the config-ninja package.
306
307    >>> spec = Installer.Spec(Path('.cn'), version='1.0')
308    >>> installer = Installer(spec)
309    >>> installer.install()
310    _VirtualEnvironment(...)
311
312    If the specified version is not available, a `ValueError` is raised:
313
314    >>> spec = Installer.Spec(Path('.cn'), version='0.9')
315    >>> installer = Installer(spec)
316    >>> with pytest.raises(ValueError):
317    ...     installer.install()
318
319    By default, the latest version available from PyPI is installed:
320
321    >>> spec = Installer.Spec(Path('.cn'))
322    >>> installer = Installer(spec)
323    >>> installer.install()
324    _VirtualEnvironment(...)
325
326    Pre-release versions are excluded unless the `pre` argument is passed:
327
328    >>> spec = Installer.Spec(Path('.cn'), pre=True)
329    >>> installer = Installer(spec)
330    >>> installer.install()
331    _VirtualEnvironment(...)
332    """
333
334    METADATA_URL = 'https://pypi.org/pypi/config-ninja/json'
335    """Retrieve the latest version of config-ninja from this URL."""
336
337    _allow_pre_releases: bool
338    _extras: str
339    _force: bool
340    _path: Path
341    _version: _Version | None
342
343    @dataclass
344    class Spec:
345        """Specify parameters for the `Installer` class."""
346
347        path: Path
348
349        extras: str = ''
350        force: bool = False
351        pre: bool = False
352        version: str | None = None
353
354    def __init__(self, spec: Spec) -> None:
355        """Initialize properties on the `Installer` object."""
356        self._path = spec.path
357
358        self._allow_pre_releases = spec.pre
359        self._extras = spec.extras
360        self._force = spec.force
361        self._version = _Version(spec.version) if spec.version else None
362
363    def _get_releases_from_pypi(self) -> list[_Version]:
364        request = Request(self.METADATA_URL, headers={'User-Agent': USER_AGENT})
365
366        with contextlib.closing(urllib.request.urlopen(request)) as response:
367            resp_bytes: bytes = response.read()
368
369        metadata: dict[str, Any] = json.loads(resp_bytes.decode('utf-8'))
370        return sorted([_Version(k) for k in metadata['releases'].keys()])
371
372    def _get_latest_release(self, releases: list[_Version]) -> _Version:
373        for version in reversed(releases):
374            if version.pre and self._allow_pre_releases:
375                return version
376
377            if not version.pre:
378                return version
379
380        raise ValueError(  # pragma: no cover
381            "Unable to find a valid release; try installing a pre-release by passing the '--pre' argument"
382        )
383
384    def _get_version(self) -> _Version:
385        releases = self._get_releases_from_pypi()
386
387        if self._version and self._version not in releases:
388            raise ValueError(f'Unable to find version: {self._version}')
389
390        if not self._version:
391            return self._get_latest_release(releases)
392
393        return self._version
394
395    @property
396    def force(self) -> bool:
397        """If truthy, skip overwrite existing paths."""
398        return self._force
399
400    def symlink(self, source: Path, check_target: Path, remove: bool = False) -> Path:
401        """Recurse up parent directories until the first 'bin' is found."""
402        if (bin_dir := check_target / 'bin').is_dir():
403            if (target := bin_dir / 'config-ninja').exists() and not self._force and not remove:
404                raise FileExistsError(target)
405
406            target.unlink(missing_ok=True)
407            if not remove:
408                os.symlink(source, target)
409            return target
410
411        if (  # pragma: no cover  # windows
412            not check_target.parent or not check_target.parent.is_dir()
413        ):
414            raise FileNotFoundError('Could not find directory for symlink')
415
416        return self.symlink(source, check_target.parent, remove)
417
418    def install(self) -> _VirtualEnvironment:
419        """Install the config-ninja package."""
420        if self._path.exists() and not self._force:
421            raise FileExistsError(f'Path already exists: {self._path}')
422
423        version = self._get_version()
424        env = _VirtualEnvironment.create(self._path)
425
426        args = ['install']
427        if self._force:
428            args.append('--upgrade')
429            args.append('--force-reinstall')
430        args.append(f'config-ninja{self._extras}=={version}')
431
432        env.pip(*args)
433
434        return env
435
436    @property
437    def path(self) -> Path:
438        """Get the installation path."""
439        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(...)
Installer(spec: Installer.Spec)
354    def __init__(self, spec: Spec) -> None:
355        """Initialize properties on the `Installer` object."""
356        self._path = spec.path
357
358        self._allow_pre_releases = spec.pre
359        self._extras = spec.extras
360        self._force = spec.force
361        self._version = _Version(spec.version) if spec.version else None

Initialize properties on the Installer object.

METADATA_URL = 'https://pypi.org/pypi/config-ninja/json'

Retrieve the latest version of config-ninja from this URL.

force: bool
395    @property
396    def force(self) -> bool:
397        """If truthy, skip overwrite existing paths."""
398        return self._force

If truthy, skip overwrite existing paths.

def install(self) -> tools.install._VirtualEnvironment:
418    def install(self) -> _VirtualEnvironment:
419        """Install the config-ninja package."""
420        if self._path.exists() and not self._force:
421            raise FileExistsError(f'Path already exists: {self._path}')
422
423        version = self._get_version()
424        env = _VirtualEnvironment.create(self._path)
425
426        args = ['install']
427        if self._force:
428            args.append('--upgrade')
429            args.append('--force-reinstall')
430        args.append(f'config-ninja{self._extras}=={version}')
431
432        env.pip(*args)
433
434        return env

Install the config-ninja package.

path: pathlib.Path
436    @property
437    def path(self) -> Path:
438        """Get the installation path."""
439        return self._path

Get the installation path.

@dataclass
class Installer.Spec:
343    @dataclass
344    class Spec:
345        """Specify parameters for the `Installer` class."""
346
347        path: Path
348
349        extras: str = ''
350        force: bool = False
351        pre: bool = False
352        version: str | None = None

Specify parameters for the Installer class.

Installer.Spec( path: pathlib.Path, extras: str = '', force: bool = False, pre: bool = False, version: str | None = None)
path: pathlib.Path
extras: str = ''
force: bool = False
pre: bool = False
version: str | None = None
def blue(text: Any) -> str:
509def blue(text: Any) -> str:
510    """Color the given text blue."""
511    return f'\033[94m{text}\033[0m'

Color the given text blue.

def cyan(text: Any) -> str:
514def cyan(text: Any) -> str:
515    """Color the given text cyan."""
516    return f'\033[96m{text}\033[0m'

Color the given text cyan.

def gray(text: Any) -> str:
519def gray(text: Any) -> str:  # pragma: no cover  # edge case / windows
520    """Color the given text gray."""
521    return f'\033[90m{text}\033[0m'

Color the given text gray.

def green(text: Any) -> str:
524def green(text: Any) -> str:
525    """Color the given text green."""
526    return f'\033[92m{text}\033[0m'

Color the given text green.

def orange(text: Any) -> str:
529def orange(text: Any) -> str:
530    """Color the given text orange."""
531    return f'\033[33m{text}\033[0m'

Color the given text orange.

def red(text: Any) -> str:
534def red(text: Any) -> str:
535    """Color the given text red."""
536    return f'\033[91m{text}\033[0m'

Color the given text red.

def yellow(text: Any) -> str:
539def yellow(text: Any) -> str:
540    """Color the given text yellow."""
541    return f'\033[93m{text}\033[0m'

Color the given text yellow.

warning = '\x1b[93mWARNING\x1b[0m'
failure = '\x1b[91mFAILURE\x1b[0m'
prompts = '\x1b[94mPROMPTS\x1b[0m'
success = '\x1b[92mSUCCESS\x1b[0m'
def main(*argv: str) -> None:
624def main(*argv: str) -> None:
625    """Install the `config-ninja` package to a virtual environment."""
626    args = _parse_args(argv)
627    install_path: Path = args.path or _get_data_dir()
628
629    spec = Installer.Spec(
630        install_path,
631        version=args.version or os.getenv('CONFIG_NINJA_VERSION'),
632        force=args.force or string_to_bool(os.getenv('CONFIG_NINJA_FORCE', 'false')),
633        pre=args.pre or string_to_bool(os.getenv('CONFIG_NINJA_PRE', 'false')),
634        extras=args.backends or os.getenv('CONFIG_NINJA_BACKENDS', '[all]'),
635    )
636
637    installer = Installer(spec)
638    if args.uninstall:
639        if not installer.path.is_dir():
640            sys.stdout.write(f'{warning}: Path does not exist: {cyan(installer.path)}\n')
641            return
642        _do_uninstall(installer)
643        return
644
645    _do_install(installer)

Install the config-ninja package to a virtual environment.