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:])
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:
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(...)
Installer(spec: Installer.Spec)
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.

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

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

force: bool
398    @property
399    def force(self) -> bool:
400        """If truthy, skip overwrite existing paths."""
401        return self._force

If truthy, skip overwrite existing paths.

def install(self) -> tools.install._VirtualEnvironment:
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.

path: pathlib.Path
439    @property
440    def path(self) -> Path:
441        """Get the installation path."""
442        return self._path

Get the installation path.

@dataclass
class Installer.Spec:
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.

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:
512def blue(text: Any) -> str:
513    """Color the given text blue."""
514    return f'\033[94m{text}\033[0m'

Color the given text blue.

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

Color the given text cyan.

def gray(text: Any) -> str:
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.

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

Color the given text green.

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

Color the given text orange.

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

Color the given text red.

def yellow(text: Any) -> str:
542def yellow(text: Any) -> str:
543    """Color the given text yellow."""
544    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:
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.