pytest-xdocker 0.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_xdocker-0.0.0/LICENSE.rst +21 -0
- pytest_xdocker-0.0.0/PKG-INFO +68 -0
- pytest_xdocker-0.0.0/README.rst +44 -0
- pytest_xdocker-0.0.0/pyproject.toml +141 -0
- pytest_xdocker-0.0.0/pytest_xdocker/__init__.py +0 -0
- pytest_xdocker-0.0.0/pytest_xdocker/cache.py +98 -0
- pytest_xdocker-0.0.0/pytest_xdocker/command.py +176 -0
- pytest_xdocker-0.0.0/pytest_xdocker/docker.py +629 -0
- pytest_xdocker-0.0.0/pytest_xdocker/docker_xrun.py +152 -0
- pytest_xdocker-0.0.0/pytest_xdocker/fixtures.py +34 -0
- pytest_xdocker-0.0.0/pytest_xdocker/lock.py +140 -0
- pytest_xdocker-0.0.0/pytest_xdocker/network.py +38 -0
- pytest_xdocker-0.0.0/pytest_xdocker/process.py +275 -0
- pytest_xdocker-0.0.0/pytest_xdocker/retry.py +190 -0
- pytest_xdocker-0.0.0/pytest_xdocker/validators.py +20 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Marc Tardif
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pytest-xdocker
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Pytest fixture to run docker across test runs.
|
|
5
|
+
Home-page: https://github.com/cr3/pytest-xdocker
|
|
6
|
+
Author: Marc Tardif
|
|
7
|
+
Requires-Python: >=3.9,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Dist: attrs (>=24.3.0,<25.0.0)
|
|
15
|
+
Requires-Dist: netifaces (>=0.11.0,<0.12.0)
|
|
16
|
+
Requires-Dist: psutil (>=6.1.1,<7.0.0)
|
|
17
|
+
Requires-Dist: pyhamcrest (>=2.1.0,<3.0.0)
|
|
18
|
+
Requires-Dist: pytest (>=7.4.2,<8.0.0)
|
|
19
|
+
Requires-Dist: pytest-cache (>=1.0,<2.0)
|
|
20
|
+
Requires-Dist: pytest-xprocess (>=1.0.2,<2.0.0)
|
|
21
|
+
Project-URL: Repository, https://github.com/cr3/pytest-xdocker
|
|
22
|
+
Description-Content-Type: text/x-rst
|
|
23
|
+
|
|
24
|
+
pytest-xdocker
|
|
25
|
+
==============
|
|
26
|
+
|
|
27
|
+
`Pytest <http://pytest.org>`_ fixture to run docker across test runs.
|
|
28
|
+
|
|
29
|
+
.. image:: https://img.shields.io/badge/license-MIT-blue.svg
|
|
30
|
+
:target: https://github.com/cr3/pytest-xdocker/blob/master/LICENSE
|
|
31
|
+
:alt: License
|
|
32
|
+
.. image:: https://img.shields.io/pypi/v/pytest-xdocker.svg
|
|
33
|
+
:target: https://pypi.python.org/pypi/pytest-xdocker/
|
|
34
|
+
:alt: PyPI
|
|
35
|
+
.. image:: https://img.shields.io/github/issues-raw/cr3/pytest-xdocker.svg
|
|
36
|
+
:target: https://github.com/cr3/pytest-xdocker/issues
|
|
37
|
+
:alt: Issues
|
|
38
|
+
|
|
39
|
+
Requirements
|
|
40
|
+
------------
|
|
41
|
+
|
|
42
|
+
You will need the following prerequisites to use pytest-xdocker:
|
|
43
|
+
|
|
44
|
+
- Python 3.9, 3.10, 3.11, 3.12, 3.13
|
|
45
|
+
|
|
46
|
+
Installation
|
|
47
|
+
------------
|
|
48
|
+
|
|
49
|
+
To install pytest-xdocker:
|
|
50
|
+
|
|
51
|
+
.. code-block:: bash
|
|
52
|
+
|
|
53
|
+
$ pip install pytest-xdocker
|
|
54
|
+
|
|
55
|
+
Usage
|
|
56
|
+
-----
|
|
57
|
+
|
|
58
|
+
TODO
|
|
59
|
+
|
|
60
|
+
Resources
|
|
61
|
+
---------
|
|
62
|
+
|
|
63
|
+
- `Documentation <https://cr3.github.io/pytest-xdocker/>`_
|
|
64
|
+
- `Release Notes <http://github.com/cr3/pytest-xdocker/blob/master/CHANGES.rst>`_
|
|
65
|
+
- `Issue Tracker <http://github.com/cr3/pytest-xdocker/issues>`_
|
|
66
|
+
- `Source Code <http://github.com/cr3/pytest-xdocker/>`_
|
|
67
|
+
- `PyPi <https://pypi.org/project/pytest-xdocker/>`_
|
|
68
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
pytest-xdocker
|
|
2
|
+
==============
|
|
3
|
+
|
|
4
|
+
`Pytest <http://pytest.org>`_ fixture to run docker across test runs.
|
|
5
|
+
|
|
6
|
+
.. image:: https://img.shields.io/badge/license-MIT-blue.svg
|
|
7
|
+
:target: https://github.com/cr3/pytest-xdocker/blob/master/LICENSE
|
|
8
|
+
:alt: License
|
|
9
|
+
.. image:: https://img.shields.io/pypi/v/pytest-xdocker.svg
|
|
10
|
+
:target: https://pypi.python.org/pypi/pytest-xdocker/
|
|
11
|
+
:alt: PyPI
|
|
12
|
+
.. image:: https://img.shields.io/github/issues-raw/cr3/pytest-xdocker.svg
|
|
13
|
+
:target: https://github.com/cr3/pytest-xdocker/issues
|
|
14
|
+
:alt: Issues
|
|
15
|
+
|
|
16
|
+
Requirements
|
|
17
|
+
------------
|
|
18
|
+
|
|
19
|
+
You will need the following prerequisites to use pytest-xdocker:
|
|
20
|
+
|
|
21
|
+
- Python 3.9, 3.10, 3.11, 3.12, 3.13
|
|
22
|
+
|
|
23
|
+
Installation
|
|
24
|
+
------------
|
|
25
|
+
|
|
26
|
+
To install pytest-xdocker:
|
|
27
|
+
|
|
28
|
+
.. code-block:: bash
|
|
29
|
+
|
|
30
|
+
$ pip install pytest-xdocker
|
|
31
|
+
|
|
32
|
+
Usage
|
|
33
|
+
-----
|
|
34
|
+
|
|
35
|
+
TODO
|
|
36
|
+
|
|
37
|
+
Resources
|
|
38
|
+
---------
|
|
39
|
+
|
|
40
|
+
- `Documentation <https://cr3.github.io/pytest-xdocker/>`_
|
|
41
|
+
- `Release Notes <http://github.com/cr3/pytest-xdocker/blob/master/CHANGES.rst>`_
|
|
42
|
+
- `Issue Tracker <http://github.com/cr3/pytest-xdocker/issues>`_
|
|
43
|
+
- `Source Code <http://github.com/cr3/pytest-xdocker/>`_
|
|
44
|
+
- `PyPi <https://pypi.org/project/pytest-xdocker/>`_
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "pytest-xdocker"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
description = "Pytest fixture to run docker across test runs."
|
|
5
|
+
authors = ["Marc Tardif"]
|
|
6
|
+
readme = "README.rst"
|
|
7
|
+
repository = "https://github.com/cr3/pytest-xdocker"
|
|
8
|
+
packages = [
|
|
9
|
+
{ include = "pytest_xdocker" },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[tool.poetry.dependencies]
|
|
13
|
+
attrs = "^24.3.0"
|
|
14
|
+
netifaces = "^0.11.0"
|
|
15
|
+
psutil = "^6.1.1"
|
|
16
|
+
pyhamcrest = "^2.1.0"
|
|
17
|
+
python = "^3.9"
|
|
18
|
+
pytest = "^7.4.2"
|
|
19
|
+
pytest-cache = "^1.0"
|
|
20
|
+
pytest-xprocess = "^1.0.2"
|
|
21
|
+
|
|
22
|
+
[tool.poetry.group.test.dependencies]
|
|
23
|
+
coverage = "^7.2.3"
|
|
24
|
+
pytest-unique = "^0.1.3"
|
|
25
|
+
yarl = "^1.18.3"
|
|
26
|
+
|
|
27
|
+
[tool.poetry.group.check]
|
|
28
|
+
optional = true
|
|
29
|
+
|
|
30
|
+
[tool.poetry.group.check.dependencies]
|
|
31
|
+
ruff = "^0.0.265"
|
|
32
|
+
black = "^23.3.0"
|
|
33
|
+
pre-commit = "^3.3.1"
|
|
34
|
+
|
|
35
|
+
[tool.poetry.group.docs]
|
|
36
|
+
optional = true
|
|
37
|
+
|
|
38
|
+
[tool.poetry.group.docs.dependencies]
|
|
39
|
+
sphinx = "^6.1.3"
|
|
40
|
+
sphinxcontrib-log-cabinet = "^1.0.1"
|
|
41
|
+
sphinx-rtd-theme = "^1.2.0"
|
|
42
|
+
|
|
43
|
+
[tool.poetry.scripts]
|
|
44
|
+
docker-xrun = "pytest_xdocker.docker_xrun:main"
|
|
45
|
+
|
|
46
|
+
[tool.poetry.plugins."pytest11"]
|
|
47
|
+
docker-xrun = "pytest_xdocker.fixtures"
|
|
48
|
+
|
|
49
|
+
[tool.poetry.plugins."pytest_unique.unique"]
|
|
50
|
+
ip = "pytest_xdocker.network:unique_ip"
|
|
51
|
+
|
|
52
|
+
[build-system]
|
|
53
|
+
requires = ["poetry-core>=1.0.0"]
|
|
54
|
+
build-backend = "poetry.core.masonry.api"
|
|
55
|
+
|
|
56
|
+
[tool.black]
|
|
57
|
+
line-length = 120
|
|
58
|
+
target-version = ["py39"]
|
|
59
|
+
preview = true
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
target-version = "py39"
|
|
63
|
+
line-length = 120
|
|
64
|
+
fix = true
|
|
65
|
+
select = [
|
|
66
|
+
# flake8-2020
|
|
67
|
+
"YTT",
|
|
68
|
+
# flake8-bandit
|
|
69
|
+
"S",
|
|
70
|
+
# flake8-bugbear
|
|
71
|
+
"B",
|
|
72
|
+
# flake8-builtins
|
|
73
|
+
"A",
|
|
74
|
+
# flake8-comprehensions
|
|
75
|
+
"C4",
|
|
76
|
+
# flake8-debugger
|
|
77
|
+
"T10",
|
|
78
|
+
# flake8-simplify
|
|
79
|
+
"SIM",
|
|
80
|
+
# isort
|
|
81
|
+
"I",
|
|
82
|
+
# mccabe
|
|
83
|
+
"C90",
|
|
84
|
+
# pycodestyle
|
|
85
|
+
"E", "W",
|
|
86
|
+
# pyflakes
|
|
87
|
+
"F",
|
|
88
|
+
# pygrep-hooks
|
|
89
|
+
"PGH",
|
|
90
|
+
# pyupgrade
|
|
91
|
+
"UP",
|
|
92
|
+
# ruff
|
|
93
|
+
"RUF",
|
|
94
|
+
# tryceratops
|
|
95
|
+
"TRY",
|
|
96
|
+
]
|
|
97
|
+
ignore = [
|
|
98
|
+
# LineTooLong
|
|
99
|
+
"E501",
|
|
100
|
+
# DoNotAssignLambda
|
|
101
|
+
"E731",
|
|
102
|
+
# Create your own exception
|
|
103
|
+
"TRY002",
|
|
104
|
+
# Avoid specifying long messages outside the exception class
|
|
105
|
+
"TRY003",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
[tool.ruff.per-file-ignores]
|
|
109
|
+
"tests/*" = ["S101"]
|
|
110
|
+
|
|
111
|
+
# Pytest options:
|
|
112
|
+
# https://docs.pytest.org/en/6.2.x/reference.html#ini-options-ref
|
|
113
|
+
[tool.pytest.ini_options]
|
|
114
|
+
addopts = [
|
|
115
|
+
"--doctest-modules",
|
|
116
|
+
"--doctest-glob=*.rst",
|
|
117
|
+
]
|
|
118
|
+
testpaths = [
|
|
119
|
+
"pytest_xdocker",
|
|
120
|
+
"docs",
|
|
121
|
+
"tests",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
# Coverage options:
|
|
125
|
+
# https://coverage.readthedocs.io/en/latest/config.html
|
|
126
|
+
[tool.coverage.paths]
|
|
127
|
+
source = [
|
|
128
|
+
"pytest_xdocker",
|
|
129
|
+
"*/*/site-packages",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
[tool.coverage.report]
|
|
133
|
+
fail_under = 90
|
|
134
|
+
show_missing = true
|
|
135
|
+
|
|
136
|
+
[tool.coverage.run]
|
|
137
|
+
branch = true
|
|
138
|
+
parallel = true
|
|
139
|
+
source = [
|
|
140
|
+
"pytest_xdocker",
|
|
141
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Cache providers."""
|
|
2
|
+
|
|
3
|
+
import codecs
|
|
4
|
+
import json
|
|
5
|
+
from abc import ABCMeta, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import attr
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cache_encode(data):
|
|
12
|
+
"""Serialize cache payload."""
|
|
13
|
+
return json.dumps(data).encode("utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cache_decode(payload):
|
|
17
|
+
"""Deserialize cache payload."""
|
|
18
|
+
return json.loads(codecs.decode(payload, "utf-8"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CacheError(Exception):
|
|
22
|
+
"""Raised with an unexpected cache error occurs."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Cache(metaclass=ABCMeta):
|
|
26
|
+
"""Base class for cache providers."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def get(self, key, default):
|
|
30
|
+
"""Return cached value for the given key or the default."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def set(self, key, value): # noqa: A003
|
|
34
|
+
"""Save value for the given key."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@attr.s(frozen=True, slots=True)
|
|
38
|
+
class FileCache(Cache):
|
|
39
|
+
"""Lightweight implementation of `pytest.cache`.
|
|
40
|
+
|
|
41
|
+
:param path: Base path to cache directory.
|
|
42
|
+
:param encode: Encoding function, defaults to `cache_encode`
|
|
43
|
+
:param decode: Decoding function, defaults to `cache_decode`
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_cachedir = attr.ib(converter=Path)
|
|
47
|
+
encode = attr.ib(default=cache_encode)
|
|
48
|
+
decode = attr.ib(default=cache_decode)
|
|
49
|
+
|
|
50
|
+
def _get_value_path(self, key):
|
|
51
|
+
path = self._cachedir / "v" / key
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
def get(self, key, default):
|
|
56
|
+
"""Read from file."""
|
|
57
|
+
path = self._get_value_path(key)
|
|
58
|
+
if path.exists():
|
|
59
|
+
payload = path.read_bytes()
|
|
60
|
+
return self.decode(payload)
|
|
61
|
+
else:
|
|
62
|
+
return default
|
|
63
|
+
|
|
64
|
+
def set(self, key, value): # noqa: A003
|
|
65
|
+
"""Write to file."""
|
|
66
|
+
path = self._get_value_path(key)
|
|
67
|
+
payload = self.encode(value)
|
|
68
|
+
path.write_bytes(payload)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@attr.s(frozen=True, slots=True)
|
|
72
|
+
class MemoryCache(Cache):
|
|
73
|
+
"""Memory cache."""
|
|
74
|
+
|
|
75
|
+
_memory = attr.ib(default=attr.Factory(dict))
|
|
76
|
+
|
|
77
|
+
def get(self, key, default):
|
|
78
|
+
"""Read from dict."""
|
|
79
|
+
return self._memory.get(key, default)
|
|
80
|
+
|
|
81
|
+
def set(self, key, value): # noqa: A003
|
|
82
|
+
"""Write the value to dict."""
|
|
83
|
+
self._memory[key] = value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@attr.s(frozen=True, slots=True)
|
|
87
|
+
class NullCache(Cache):
|
|
88
|
+
"""Null cache.
|
|
89
|
+
|
|
90
|
+
This cache never sets a value and always gets the default value.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def get(self, key, default):
|
|
94
|
+
"""Noop."""
|
|
95
|
+
return default
|
|
96
|
+
|
|
97
|
+
def set(self, key, value): # noqa: A003
|
|
98
|
+
"""Noop."""
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Module to build shell commands declaratively.
|
|
2
|
+
|
|
3
|
+
A Command instance can be used to build a shell command:
|
|
4
|
+
|
|
5
|
+
>>> command = Command('whoami')
|
|
6
|
+
|
|
7
|
+
The command can then be executed later:
|
|
8
|
+
|
|
9
|
+
>>> lines = command.execute().splitlines()
|
|
10
|
+
>>> len(lines)
|
|
11
|
+
1
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from itertools import chain
|
|
21
|
+
from shlex import quote
|
|
22
|
+
from subprocess import check_output
|
|
23
|
+
|
|
24
|
+
import attr
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@attr.s(eq=False, frozen=True, repr=False, slots=True)
|
|
28
|
+
class Command(Iterable):
|
|
29
|
+
"""Manages a shell command."""
|
|
30
|
+
|
|
31
|
+
_command = attr.ib(converter=str)
|
|
32
|
+
_parent = attr.ib(default=iter(()))
|
|
33
|
+
_positionals = attr.ib(factory=list)
|
|
34
|
+
_optionals = attr.ib(factory=list)
|
|
35
|
+
|
|
36
|
+
def __eq__(self, other):
|
|
37
|
+
return list(self) == other
|
|
38
|
+
|
|
39
|
+
def __ne__(self, other):
|
|
40
|
+
return not self == other
|
|
41
|
+
|
|
42
|
+
def __iter__(self):
|
|
43
|
+
return chain(
|
|
44
|
+
self._parent,
|
|
45
|
+
[self._command],
|
|
46
|
+
self._optionals,
|
|
47
|
+
self._positionals,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def __repr__(self):
|
|
51
|
+
cls = self.__class__.__name__
|
|
52
|
+
args = ", ".join(repr(arg) for arg in self)
|
|
53
|
+
return f"{cls}([{args}])"
|
|
54
|
+
|
|
55
|
+
def __str__(self):
|
|
56
|
+
return self.to_string()
|
|
57
|
+
|
|
58
|
+
def to_string(self, escape=None):
|
|
59
|
+
"""Stringify the command."""
|
|
60
|
+
if escape is None:
|
|
61
|
+
escape = quote
|
|
62
|
+
|
|
63
|
+
return " ".join(escape(part) for part in self)
|
|
64
|
+
|
|
65
|
+
def with_positionals(self, *positionals):
|
|
66
|
+
"""Add positional args."""
|
|
67
|
+
return attr.evolve(self, positionals=self._positionals + list(positionals))
|
|
68
|
+
|
|
69
|
+
def with_optionals(self, *optionals):
|
|
70
|
+
"""Add optional args."""
|
|
71
|
+
return attr.evolve(self, optionals=self._optionals + list(optionals))
|
|
72
|
+
|
|
73
|
+
def reparent(self, parent=None):
|
|
74
|
+
"""Add a wrapping command."""
|
|
75
|
+
if parent is None:
|
|
76
|
+
parent = iter(())
|
|
77
|
+
return attr.evolve(self, parent=parent)
|
|
78
|
+
|
|
79
|
+
def execute(self, **kwargs):
|
|
80
|
+
"""Run the command."""
|
|
81
|
+
logging.info("Executing command: %s", self)
|
|
82
|
+
kwargs.setdefault("universal_newlines", True)
|
|
83
|
+
return check_output(self, **kwargs) # noqa: S603
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def empty_type():
|
|
87
|
+
"""Option arg for an undefined optional arg."""
|
|
88
|
+
return args_type(min=0, max=0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def const_type(const):
|
|
92
|
+
"""Option arg for a constant string."""
|
|
93
|
+
return (const,)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def arg_type(arg, **kwargs):
|
|
97
|
+
"""Option type for a single args."""
|
|
98
|
+
return args_type(arg, min=1, max=1, **kwargs)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def args_type(*args, **kwargs):
|
|
102
|
+
"""Option type for multiple args."""
|
|
103
|
+
options = {
|
|
104
|
+
"converter": lambda arg: arg,
|
|
105
|
+
"min": 0,
|
|
106
|
+
"max": sys.maxsize,
|
|
107
|
+
}
|
|
108
|
+
options.update(kwargs)
|
|
109
|
+
|
|
110
|
+
if len(args) < options["min"]:
|
|
111
|
+
raise ValueError(f"Expected at least {options['min']} args, got: {args!r}")
|
|
112
|
+
if len(args) > options["max"]:
|
|
113
|
+
raise ValueError(f"Expected at most {options['max']} args, got: {args!r}")
|
|
114
|
+
|
|
115
|
+
return tuple(options["converter"](arg) for arg in args)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class OptionalArg:
|
|
119
|
+
"""Descriptor for optional arguments.
|
|
120
|
+
|
|
121
|
+
:param name: Name of the option.
|
|
122
|
+
:param type: Optional argument type, defaults to `empty_type`.
|
|
123
|
+
:param kwargs: Optional keyword arguments passed to the type.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, name, type=empty_type, **kwargs): # noqa: A002
|
|
127
|
+
"""Init."""
|
|
128
|
+
self._name = name
|
|
129
|
+
self._type = type
|
|
130
|
+
self._kwargs = kwargs
|
|
131
|
+
|
|
132
|
+
def __get__(self, obj, cls=None):
|
|
133
|
+
def with_func(*args):
|
|
134
|
+
values = self._type(*args, **self._kwargs)
|
|
135
|
+
return obj.with_optionals(self._name, *values)
|
|
136
|
+
|
|
137
|
+
return with_func
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class PositionalArg:
|
|
141
|
+
"""Descriptor for positional arguments.
|
|
142
|
+
|
|
143
|
+
:param type: Optional argument type, defaults to `arg_type`.
|
|
144
|
+
:param kwargs: Optional keyword arguments passed to the type.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self, type=arg_type, **kwargs): # noqa: A002
|
|
148
|
+
"""Init."""
|
|
149
|
+
self._type = type
|
|
150
|
+
self._kwargs = kwargs
|
|
151
|
+
|
|
152
|
+
def __get__(self, obj, cls=None):
|
|
153
|
+
def with_func(*args):
|
|
154
|
+
values = self._type(*args, **self._kwargs)
|
|
155
|
+
return obj.with_positionals(*values)
|
|
156
|
+
|
|
157
|
+
return with_func
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def script_to_command(script, cls=Command):
|
|
161
|
+
"""
|
|
162
|
+
On Windows, console scripts created as .exe should be called directly
|
|
163
|
+
and those created as .cmd with Python.
|
|
164
|
+
"""
|
|
165
|
+
path = shutil.which(script)
|
|
166
|
+
if path is None:
|
|
167
|
+
raise OSError(f"Script not found: {script}")
|
|
168
|
+
|
|
169
|
+
base, ext = os.path.splitext(path)
|
|
170
|
+
if re.match(r".cmd", ext, re.IGNORECASE):
|
|
171
|
+
parent = Command(shutil.which("python"))
|
|
172
|
+
command = cls(base, parent)
|
|
173
|
+
else:
|
|
174
|
+
command = cls(path)
|
|
175
|
+
|
|
176
|
+
return command
|