Skip to content

Commit 6920094

Browse files
committed
First version
Signed-off-by: Bernát Gábor <[email protected]>
1 parent 7a3226e commit 6920094

File tree

16 files changed

+392
-164
lines changed

16 files changed

+392
-164
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ __pycache__
88
**.pyc
99
build
1010
dist
11-
src/pytest_devpi/version.py
11+
src/devpi_process/version.py

CHANGELOG.md

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,5 @@
11
# Changelog
22

3-
## [Unreleased](https://github.com/gaborbernat/pytest-devpi/tree/HEAD)
4-
5-
## [0.1.0](https://github.com/gaborbernat/pytest-devpi/tree/0.1.0) (2021-06-17)
6-
7-
[Full Changelog](https://github.com/gaborbernat/pytest-devpi/compare/0.2.1...0.3.0)
8-
9-
**Merged pull requests:**
10-
11-
- Drop Python 2 support [\#6](https://github.com/gaborbernat/pytest-devpi/issues/7)
12-
13-
## [0.2.1](https://github.com/gaborbernat/pytest-devpi/tree/0.2.1) (2020-10-23)
14-
15-
[Full Changelog](https://github.com/gaborbernat/pytest-devpi/compare/0.2.0...0.2.1)
16-
17-
**Implemented enhancements:**
18-
19-
- add session level support [\#6](https://github.com/gaborbernat/pytest-devpi/issues/6)
20-
21-
## [0.2.0](https://github.com/gaborbernat/pytest-devpi/tree/0.2.0) (2020-08-04)
22-
23-
[Full Changelog](https://github.com/gaborbernat/pytest-devpi/compare/0.1.3...0.2.0)
24-
25-
## [0.1.3](https://github.com/gaborbernat/pytest-devpi/tree/0.1.3) (2019-09-03)
26-
27-
[Full Changelog](https://github.com/gaborbernat/pytest-devpi/compare/0.1.2...0.1.3)
28-
29-
**Implemented enhancements:**
30-
31-
- allow force on, document flags [\#3](https://github.com/gaborbernat/pytest-devpi/pull/3) ([gaborbernat](https://github.com/gaborbernat))
32-
33-
**Merged pull requests:**
34-
35-
- Remove PyPy special cases [\#4](https://github.com/gaborbernat/pytest-devpi/pull/4) ([vtbassmatt](https://github.com/vtbassmatt))
36-
37-
## [0.1.2](https://github.com/gaborbernat/pytest-devpi/tree/0.1.2) (2018-11-29)
38-
39-
[Full Changelog](https://github.com/gaborbernat/pytest-devpi/compare/0.1.1...0.1.2)
40-
41-
## [0.1.1](https://github.com/gaborbernat/pytest-devpi/tree/0.1.1) (2018-11-15)
42-
43-
[Full Changelog](https://github.com/gaborbernat/pytest-devpi/compare/0.1.0...0.1.1)
44-
45-
**Closed issues:**
46-
47-
- How to use “pytest\_print” [\#1](https://github.com/gaborbernat/pytest-devpi/issues/1)
48-
49-
**Merged pull requests:**
50-
51-
- Update setup.py [\#2](https://github.com/gaborbernat/pytest-devpi/pull/2) ([shashanksingh28](https://github.com/shashanksingh28))
52-
53-
## [0.1.0](https://github.com/gaborbernat/pytest-devpi/tree/0.1.0) (2018-04-14)
54-
55-
[Full Changelog](https://github.com/gaborbernat/pytest-devpi/compare/727896d18cab117ad84010086cbc4c9a16d9e8f7...0.1.0)
56-
573

584

595
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

README.md

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,39 @@
1-
# pytest-devpi
2-
3-
[![PyPI](https://img.shields.io/pypi/v/pytest-devpi?style=flat-square)](https://pypi.org/project/pytest-devpi)
4-
[![PyPI - Implementation](https://img.shields.io/pypi/implementation/pytest-devpi?style=flat-square)](https://pypi.org/project/pytest-devpi)
5-
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytest-devpi?style=flat-square)](https://pypi.org/project/pytest-devpi)
6-
[![PyPI - Downloads](https://img.shields.io/pypi/dm/pytest-devpi?style=flat-square)](https://pypistats.org/packages/pytest-devpi)
7-
[![PyPI - License](https://img.shields.io/pypi/l/pytest-devpi?style=flat-square)](https://opensource.org/licenses/MIT)
8-
[![check](https://github.com/gaborbernat/pytest-devpi/workflows/check/badge.svg)](https://github.com/gaborbernat/pytest-devpi/actions?query=workflow%3Acheck)
1+
# devpi-process
2+
3+
[![PyPI](https://img.shields.io/pypi/v/devpi-process?style=flat-square)](https://pypi.org/project/devpi-process)
4+
[![PyPI - Implementation](https://img.shields.io/pypi/implementation/devpi-process?style=flat-square)](https://pypi.org/project/devpi-process)
5+
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/devpi-process?style=flat-square)](https://pypi.org/project/devpi-process)
6+
[![PyPI - Downloads](https://img.shields.io/pypi/dm/devpi-process?style=flat-square)](https://pypistats.org/packages/devpi-process)
7+
[![PyPI - License](https://img.shields.io/pypi/l/devpi-process?style=flat-square)](https://opensource.org/licenses/MIT)
8+
[![check](https://github.com/gaborbernat/devpi-process/workflows/check/badge.svg)](https://github.com/gaborbernat/devpi-process/actions?query=workflow%3Acheck)
99
[![Code style:
1010
black](https://img.shields.io/badge/code%20style-black-000000.svg?style=flat-square)](https://github.com/psf/black)
1111

12-
Create a devpi instance for your pytest suite.
12+
Allows you to create [devpi](https://devpi.net/docs/devpi/devpi/stable/+d/index.html) server process with indexes, and
13+
upload artifacts to that programmatically.
1314

1415
## install
1516

1617
```sh
17-
pip install pytest-devpi
18+
pip install devpi-process
19+
```
20+
21+
## use
22+
23+
```python
24+
from pathlib import Path
25+
26+
from devpi_process import Index, IndexServer
27+
28+
with IndexServer(Path("server-dir")) as server:
29+
# create an index mirroring an Artifactory instance
30+
magic_index_url = "https://magic.com/artifactory/api/pypi/magic-pypi/simple"
31+
base_name = "magic"
32+
server.create_index(base_name, "type=mirror", f"mirror_url={magic_index_url}")
33+
34+
# create a dev index server that bases of magic PyPI, and upload a wheel to it
35+
dev: Index = server.create_index("dev", f"bases={server.user}/{base_name}")
36+
dev.upload("magic-2.24.0-py3-none-any.whl")
37+
38+
assert dev.url # point the tool consuming the index server to this
1839
```

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ line-length = 120
77

88
[tool.isort]
99
profile = "black"
10-
known_first_party = ["pytest_devpi"]
10+
known_first_party = ["devpi_process"]
1111

1212
[tool.setuptools_scm]
13-
write_to = "src/pytest_devpi/version.py"
13+
write_to = "src/devpi_process/version.py"
1414
write_to_template = """
1515
\"\"\" Version information \"\"\"
1616

setup.cfg

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
[metadata]
2-
name = pytest_devpi
3-
description = pytest-devpi adds a fixture to create devpi instances within your tests
2+
name = devpi_process
3+
description = devpi process provides a programmatic API to create and use a devpi server process
44
long_description = file: README.md
55
long_description_content_type = text/markdown
6-
url = https://github.com/gaborbernat/pytest-devpi#pytest-devpi
6+
url = https://github.com/gaborbernat/devpi_process
77
maintainer = Bernat Gabor
88
maintainer_email = [email protected]
99
license = MIT
1010
license_file = LICENSE.txt
1111
platforms = any
1212
classifiers =
1313
Development Status :: 5 - Production/Stable
14-
Environment :: Plugins
15-
Framework :: Pytest
1614
Intended Audience :: Developers
1715
License :: OSI Approved :: MIT License
1816
Operating System :: MacOS :: MacOS X
@@ -26,19 +24,17 @@ classifiers =
2624
Programming Language :: Python :: 3.9
2725
Programming Language :: Python :: 3.10
2826
Topic :: Software Development :: Libraries
29-
Topic :: Software Development :: Testing
3027
Topic :: Utilities
31-
keywords = pytest, print, debug
28+
keywords = devpi, programmatic
3229
project_urls =
33-
Source=https://github.com/gaborbernat/pytest-devpi
34-
Tracker=https://github.com/gaborbernat/pytest-devpi/issues
30+
Source=https://github.com/gaborbernat/devpi-process
31+
Tracker=https://github.com/gaborbernat/devpi-process
3532

3633
[options]
3734
packages = find:
3835
install_requires =
3936
devpi-client>=5.2
4037
devpi-server>=6
41-
pytest>=6
4238
python_requires = >=3.6
4339
include_package_data = True
4440
package_dir =
@@ -48,22 +44,18 @@ zip_safe = True
4844
[options.packages.find]
4945
where = src
5046

51-
[options.entry_points]
52-
pytest11 = pytest_devpi = pytest_devpi
53-
5447
[options.extras_require]
5548
test =
5649
coverage>=5
50+
httpx>=0.18
51+
pytest>=6
5752

5853
[options.package_data]
59-
pytest_devpi = py.typed
54+
devpi_process = py.typed
6055

6156
[sdist]
6257
formats = gztar
6358

64-
[bdist_wheel]
65-
universal = true
66-
6759
[flake8]
6860
max-line-length = 120
6961
ignore = F401, H301, E203
@@ -80,8 +72,6 @@ dynamic_context = test_function
8072
fail_under = 100
8173
skip_covered = true
8274
show_missing = true
83-
omit =
84-
tests/example.py
8575

8676
[coverage:html]
8777
show_contexts = True
@@ -98,9 +88,8 @@ source =
9888
*\src
9989

10090
[tool:pytest]
101-
addopts = -ra --showlocals -vv
91+
addopts = -ra --showlocals
10292
testpaths = tests
103-
xfail_strict = True
10493
junit_family = xunit2
10594

10695
[mypy]
@@ -121,3 +110,6 @@ implicit_reexport = False
121110
strict_equality = True
122111
warn_unused_configs = True
123112
pretty = True
113+
114+
[mypy-httpx.*]
115+
ignore_missing_imports = True

src/devpi_process/__init__.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import random
2+
import socket
3+
import string
4+
import sys
5+
import sysconfig
6+
from contextlib import closing
7+
from pathlib import Path
8+
from subprocess import PIPE, Popen, check_call
9+
from threading import Thread
10+
from types import TracebackType
11+
from typing import IO, Dict, Iterator, List, Optional, Sequence, Type, cast
12+
13+
from .version import __version__
14+
15+
16+
class Index:
17+
def __init__(self, base_url: str, name: str, user: str, client_cmd_base: List[str]) -> None:
18+
self._client_cmd_base = client_cmd_base
19+
self._server_url = base_url
20+
self.name = name
21+
self.user = user
22+
23+
@property
24+
def url(self) -> str:
25+
return f"{self._server_url}/{self.name}/+simple"
26+
27+
def use(self) -> None:
28+
check_call(self._client_cmd_base + ["use", f"{self.user}/{self.name}"], stdout=PIPE, stderr=PIPE)
29+
30+
def upload(self, *files: Path) -> None:
31+
cmd = self._client_cmd_base + ["upload", "--index", self.name] + [str(i) for i in files]
32+
check_call(cmd)
33+
34+
def __repr__(self) -> str:
35+
return f"{self.__class__.__name__}(url={self.url})"
36+
37+
38+
class IndexServer:
39+
def __init__(self, path: Path, with_root_pypi: bool = False, start_args: Optional[Sequence[str]] = None) -> None:
40+
self.path = path
41+
self._with_root_pypi = with_root_pypi
42+
self._start_args: Sequence[str] = [] if start_args is None else start_args
43+
44+
self.host, self.port = "localhost", _find_free_port()
45+
self._passwd = "".join(random.choices(string.ascii_letters, k=8))
46+
47+
scripts_dir = sysconfig.get_path("scripts")
48+
if scripts_dir is None:
49+
raise RuntimeError("could not get scripts folder of host interpreter") # pragma: no cover
50+
51+
def _exe(name: str) -> str:
52+
return str(Path(cast(str, scripts_dir)) / f"{name}{'.exe' if sys.platform == 'win32' else ''}")
53+
54+
self._init: str = _exe("devpi-init")
55+
self._server: str = _exe("devpi-server")
56+
self._client: str = _exe("devpi")
57+
58+
self._server_dir = self.path / "server"
59+
self._client_dir = self.path / "client"
60+
self._indexes: Dict[str, Index] = {}
61+
self._process: Optional["Popen[str]"] = None
62+
self._has_use = False
63+
self._stdout_drain: Optional[Thread] = None
64+
65+
@property
66+
def user(self) -> str:
67+
return "root"
68+
69+
def __enter__(self) -> "IndexServer":
70+
self._create_and_start_server()
71+
self._setup_client()
72+
return self
73+
74+
def _create_and_start_server(self) -> None:
75+
self._server_dir.mkdir(exist_ok=True)
76+
server_at = str(self._server_dir)
77+
# 1. create the server
78+
cmd = [self._init, "--serverdir", server_at]
79+
cmd.extend(("--role", "standalone", "--root-passwd", self._passwd))
80+
if self._with_root_pypi is False:
81+
cmd.append("--no-root-pypi")
82+
check_call(cmd, stdout=PIPE, stderr=PIPE)
83+
# 2. start the server
84+
cmd = [self._server, "--serverdir", server_at, "--port", str(self.port)]
85+
cmd.extend(self._start_args)
86+
self._process = Popen(cmd, stdout=PIPE, universal_newlines=True)
87+
stdout = self._drain_stdout()
88+
for line in stdout: # pragma: no branch # will always loop at least once
89+
if "serving at url" in line:
90+
91+
def _keep_draining() -> None:
92+
for _ in stdout:
93+
pass
94+
95+
# important to keep draining the stdout, otherwise once the buffer is full Windows blocks the process
96+
self._stdout_drain = Thread(target=_keep_draining, name="tox-test-stdout-drain")
97+
self._stdout_drain.start()
98+
break
99+
100+
def _drain_stdout(self) -> Iterator[str]:
101+
process = cast("Popen[str]", self._process)
102+
stdout = cast(IO[str], process.stdout)
103+
while True:
104+
if process.poll() is not None: # pragma: no cover
105+
print(f"devpi server with pid {process.pid} at {self._server_dir} died")
106+
break
107+
yield stdout.readline()
108+
109+
def _setup_client(self) -> None:
110+
"""create a user on the server and authenticate it"""
111+
self._client_dir.mkdir(exist_ok=True)
112+
base = ["--clientdir", str(self._client_dir)]
113+
check_call([self._client, "use"] + base + [self.url], stdout=PIPE, stderr=PIPE)
114+
check_call([self._client, "login"] + base + [self.user, "--password", self._passwd], stdout=PIPE, stderr=PIPE)
115+
116+
def create_index(self, name: str, *args: str) -> Index:
117+
if name in self._indexes: # pragma: no cover
118+
raise ValueError(f"index {name} already exists")
119+
base = [self._client, "--clientdir", str(self._client_dir)]
120+
check_call(base + ["index", "-c", name, *args], stdout=PIPE, stderr=PIPE)
121+
index = Index(f"{self.url}/{self.user}", name, self.user, base)
122+
self._indexes[name] = index
123+
return index
124+
125+
def __exit__(
126+
self,
127+
exc_type: Optional[Type[BaseException]], # noqa: U100
128+
exc_val: Optional[BaseException], # noqa: U100
129+
exc_tb: Optional[TracebackType], # noqa: U100
130+
) -> None:
131+
if self._process is not None: # pragma: no cover # defend against devpi startup fail
132+
self._process.terminate()
133+
if self._stdout_drain is not None and self._stdout_drain.is_alive(): # pragma: no cover # devpi startup fail
134+
self._stdout_drain.join()
135+
136+
@property
137+
def url(self) -> str:
138+
return f"http://{self.host}:{self.port}"
139+
140+
def __repr__(self) -> str:
141+
return f"{self.__class__.__name__}(url={self.url}, indexes={list(self._indexes)})"
142+
143+
144+
def _find_free_port() -> int:
145+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as socket_handler:
146+
socket_handler.bind(("", 0))
147+
return cast(int, socket_handler.getsockname()[1])
148+
149+
150+
__all__ = [
151+
"__version__",
152+
"Index",
153+
"IndexServer",
154+
]
File renamed without changes.

0 commit comments

Comments
 (0)