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:])
RC_INVALID_PYTHON = 1
RC_PATH_EXISTS = 2
PACKAGE_EXTRAS = ['all', 'appconfig', 'local']
MACOS = False
MINGW = False
SHELL = $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:
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(...)
Installer(spec: Installer.Spec)
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.

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

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

force: bool
402    @property
403    def force(self) -> bool:
404        """If truthy, skip overwrite existing paths."""
405        return self._force

If truthy, skip overwrite existing paths.

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

path: pathlib.Path
443    @property
444    def path(self) -> Path:
445        """Get the installation path."""
446        return self._path

Get the installation path.

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

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

Color the given text blue.

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

Color the given text cyan.

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

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

Color the given text green.

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

Color the given text orange.

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

Color the given text red.

def yellow(text: Any) -> str:
546def yellow(text: Any) -> str:
547    """Color the given text yellow."""
548    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:
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.