pytest-twinport 0.1.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_twinport-0.1.0/.github/dependabot.yml +9 -0
- pytest_twinport-0.1.0/.github/workflows/ci.yml +62 -0
- pytest_twinport-0.1.0/.github/workflows/release.yml +49 -0
- pytest_twinport-0.1.0/.gitignore +220 -0
- pytest_twinport-0.1.0/LICENSE +21 -0
- pytest_twinport-0.1.0/PKG-INFO +252 -0
- pytest_twinport-0.1.0/README.md +229 -0
- pytest_twinport-0.1.0/pyproject.toml +56 -0
- pytest_twinport-0.1.0/scripts/stress.py +138 -0
- pytest_twinport-0.1.0/src/pytest_twinport/__init__.py +6 -0
- pytest_twinport-0.1.0/src/pytest_twinport/_lease.py +147 -0
- pytest_twinport-0.1.0/src/pytest_twinport/_ranges.py +104 -0
- pytest_twinport-0.1.0/src/pytest_twinport/plugin.py +24 -0
- pytest_twinport-0.1.0/src/pytest_twinport/py.typed +0 -0
- pytest_twinport-0.1.0/tests/test_lease.py +211 -0
- pytest_twinport-0.1.0/tests/test_plugin.py +89 -0
- pytest_twinport-0.1.0/tests/test_ranges.py +87 -0
- pytest_twinport-0.1.0/tests/test_stress.py +26 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_call:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
lint:
|
|
14
|
+
name: Lint and type check
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
timeout-minutes: 10
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
19
|
+
with:
|
|
20
|
+
persist-credentials: false
|
|
21
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.11"
|
|
24
|
+
- run: uv run ruff check
|
|
25
|
+
- run: uv run ruff format --check
|
|
26
|
+
- run: uv run mypy
|
|
27
|
+
|
|
28
|
+
test:
|
|
29
|
+
name: ${{ matrix.os }}, Python ${{ matrix.python }}
|
|
30
|
+
runs-on: ${{ matrix.os }}
|
|
31
|
+
timeout-minutes: 10
|
|
32
|
+
strategy:
|
|
33
|
+
fail-fast: false
|
|
34
|
+
matrix:
|
|
35
|
+
os: [ubuntu-latest]
|
|
36
|
+
python: ["3.11", "3.12", "3.13", "3.14", "3.15"]
|
|
37
|
+
include:
|
|
38
|
+
- os: macos-latest
|
|
39
|
+
python: "3.15"
|
|
40
|
+
- os: windows-latest
|
|
41
|
+
python: "3.15"
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
44
|
+
with:
|
|
45
|
+
persist-credentials: false
|
|
46
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
47
|
+
with:
|
|
48
|
+
python-version: ${{ matrix.python }}
|
|
49
|
+
- run: uv run pytest
|
|
50
|
+
|
|
51
|
+
lowest-dependencies:
|
|
52
|
+
name: Lowest supported pytest
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
timeout-minutes: 10
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
57
|
+
with:
|
|
58
|
+
persist-credentials: false
|
|
59
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
60
|
+
with:
|
|
61
|
+
python-version: "3.11"
|
|
62
|
+
- run: uv run --no-dev --resolution lowest-direct pytest
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
uses: ./.github/workflows/ci.yml
|
|
13
|
+
|
|
14
|
+
build:
|
|
15
|
+
needs: test
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
timeout-minutes: 10
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
20
|
+
with:
|
|
21
|
+
persist-credentials: false
|
|
22
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
23
|
+
- name: Check that the tag matches the project version
|
|
24
|
+
run: |
|
|
25
|
+
version=$(uv version --short)
|
|
26
|
+
if [ "$GITHUB_REF_NAME" != "v$version" ]; then
|
|
27
|
+
echo "::error::Tag $GITHUB_REF_NAME does not match version $version in pyproject.toml"
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
- run: uv build
|
|
31
|
+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
32
|
+
with:
|
|
33
|
+
name: dist
|
|
34
|
+
path: dist/
|
|
35
|
+
if-no-files-found: error
|
|
36
|
+
|
|
37
|
+
publish:
|
|
38
|
+
needs: build
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
timeout-minutes: 10
|
|
41
|
+
environment: pypi
|
|
42
|
+
permissions:
|
|
43
|
+
id-token: write # PyPI Trusted Publishing
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
46
|
+
with:
|
|
47
|
+
name: dist
|
|
48
|
+
path: dist/
|
|
49
|
+
- uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
*.lcov
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
# Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# uv
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# uv.lock
|
|
103
|
+
|
|
104
|
+
# poetry
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
106
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
107
|
+
# commonly ignored for libraries.
|
|
108
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
109
|
+
# poetry.lock
|
|
110
|
+
# poetry.toml
|
|
111
|
+
|
|
112
|
+
# pdm
|
|
113
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
114
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
115
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
116
|
+
# pdm.lock
|
|
117
|
+
# pdm.toml
|
|
118
|
+
.pdm-python
|
|
119
|
+
.pdm-build/
|
|
120
|
+
|
|
121
|
+
# pixi
|
|
122
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
123
|
+
# pixi.lock
|
|
124
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
125
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
126
|
+
.pixi/*
|
|
127
|
+
!.pixi/config.toml
|
|
128
|
+
|
|
129
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
130
|
+
__pypackages__/
|
|
131
|
+
|
|
132
|
+
# Celery stuff
|
|
133
|
+
celerybeat-schedule*
|
|
134
|
+
celerybeat.pid
|
|
135
|
+
|
|
136
|
+
# Redis
|
|
137
|
+
*.rdb
|
|
138
|
+
*.aof
|
|
139
|
+
*.pid
|
|
140
|
+
|
|
141
|
+
# RabbitMQ
|
|
142
|
+
mnesia/
|
|
143
|
+
rabbitmq/
|
|
144
|
+
rabbitmq-data/
|
|
145
|
+
|
|
146
|
+
# ActiveMQ
|
|
147
|
+
activemq-data/
|
|
148
|
+
|
|
149
|
+
# SageMath parsed files
|
|
150
|
+
*.sage.py
|
|
151
|
+
|
|
152
|
+
# Environments
|
|
153
|
+
.env
|
|
154
|
+
.envrc
|
|
155
|
+
.venv
|
|
156
|
+
env/
|
|
157
|
+
venv/
|
|
158
|
+
ENV/
|
|
159
|
+
env.bak/
|
|
160
|
+
venv.bak/
|
|
161
|
+
|
|
162
|
+
# Spyder project settings
|
|
163
|
+
.spyderproject
|
|
164
|
+
.spyproject
|
|
165
|
+
|
|
166
|
+
# Rope project settings
|
|
167
|
+
.ropeproject
|
|
168
|
+
|
|
169
|
+
# mkdocs/Zensical documentation
|
|
170
|
+
/site
|
|
171
|
+
|
|
172
|
+
# mypy
|
|
173
|
+
.mypy_cache/
|
|
174
|
+
.dmypy.json
|
|
175
|
+
dmypy.json
|
|
176
|
+
|
|
177
|
+
# Pyre type checker
|
|
178
|
+
.pyre/
|
|
179
|
+
|
|
180
|
+
# pytype static type analyzer
|
|
181
|
+
.pytype/
|
|
182
|
+
|
|
183
|
+
# Cython debug symbols
|
|
184
|
+
cython_debug/
|
|
185
|
+
|
|
186
|
+
# PyCharm
|
|
187
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
188
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
189
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
190
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
191
|
+
# .idea/
|
|
192
|
+
|
|
193
|
+
# Abstra
|
|
194
|
+
# Abstra is an AI-powered process automation framework.
|
|
195
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
196
|
+
# Learn more at https://abstra.io/docs
|
|
197
|
+
.abstra/
|
|
198
|
+
|
|
199
|
+
# Visual Studio Code
|
|
200
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that
|
|
201
|
+
# can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
202
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer, you
|
|
203
|
+
# could uncomment the following to ignore the entire vscode folder
|
|
204
|
+
# .vscode/
|
|
205
|
+
# Temporary file for partial code execution
|
|
206
|
+
tempCodeRunnerFile.py
|
|
207
|
+
|
|
208
|
+
# Ruff stuff:
|
|
209
|
+
.ruff_cache/
|
|
210
|
+
|
|
211
|
+
# PyPI configuration file
|
|
212
|
+
.pypirc
|
|
213
|
+
|
|
214
|
+
# Marimo
|
|
215
|
+
marimo/_static/
|
|
216
|
+
marimo/_lsp/
|
|
217
|
+
__marimo__/
|
|
218
|
+
|
|
219
|
+
# Streamlit
|
|
220
|
+
.streamlit/secrets.toml
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Petr Messner
|
|
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,252 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-twinport
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pytest plugin that leases free TCP/UDP ports without race conditions between parallel test runs
|
|
5
|
+
Project-URL: Repository, https://github.com/messa/pytest-twinport
|
|
6
|
+
Project-URL: Issues, https://github.com/messa/pytest-twinport/issues
|
|
7
|
+
Author: Petr Messner
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: e2e,free port,port,pytest,unused port,xdist
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: Pytest
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: pytest>=7
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pytest-twinport
|
|
25
|
+
|
|
26
|
+
Pytest plugin that gives your tests a free TCP/UDP port, without collisions
|
|
27
|
+
between tests running in parallel.
|
|
28
|
+
|
|
29
|
+
The usual recipe, `bind(0)`, read the port number, close the socket, has a race
|
|
30
|
+
condition: between closing the socket and starting your server, anyone else can
|
|
31
|
+
get the same port. With many E2E tests running at once, this shows up as random
|
|
32
|
+
`Address already in use` failures.
|
|
33
|
+
|
|
34
|
+
`pytest-twinport` keeps the port reserved for the whole test, across processes
|
|
35
|
+
and across separate pytest runs. It needs no daemon and no lock files.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install pytest-twinport
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
Use the `twinport` fixture to get a port reserved until the end of the test:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
def test_server(twinport):
|
|
49
|
+
server = start_server(port=twinport)
|
|
50
|
+
...
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use `twinport_factory` when a test needs more ports:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
def test_cluster(twinport_factory):
|
|
57
|
+
ports = [twinport_factory() for _ in range(3)]
|
|
58
|
+
...
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use `lease_port()` for any other fixture scope, or outside of fixtures. Make the
|
|
62
|
+
server fixture depend on the lease, so that the server stops first:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import pytest
|
|
66
|
+
from pytest_twinport import lease_port
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.fixture(scope="session")
|
|
70
|
+
def server():
|
|
71
|
+
with lease_port() as port:
|
|
72
|
+
process = start_server(port=port)
|
|
73
|
+
yield process
|
|
74
|
+
process.stop()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`lease_port()` can also be used without `with`. Keep the returned object for as
|
|
78
|
+
long as you need the port, because a garbage collected lease is released:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
lease = lease_port()
|
|
82
|
+
start_server(port=lease.port)
|
|
83
|
+
...
|
|
84
|
+
lease.release()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If your server can bind to port 0 and report the port it got, you don't need
|
|
88
|
+
this plugin. It is meant for servers that must be told their port up front, such
|
|
89
|
+
as external processes, Docker containers and services configured by a file.
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
Ports are split into fixed pairs: an even _guard_ port and the odd port after it.
|
|
94
|
+
|
|
95
|
+
1. Pick a random pair and bind the guard port. `bind()` is atomic, so only one
|
|
96
|
+
process can succeed.
|
|
97
|
+
2. Check that the odd port is free.
|
|
98
|
+
3. Give the odd port to the caller and keep the guard bound.
|
|
99
|
+
|
|
100
|
+
Other processes can't take the pair, because they fail on the guard. When the
|
|
101
|
+
process ends, even with `SIGKILL`, the kernel closes the guard. Nothing stale is
|
|
102
|
+
left behind.
|
|
103
|
+
|
|
104
|
+
The lock lives in the network namespace of the process that holds it, so it only
|
|
105
|
+
protects ports used in that same namespace. See the note on containers below.
|
|
106
|
+
|
|
107
|
+
Ports are only chosen outside the ephemeral range. This matters: outgoing
|
|
108
|
+
connections take their source port from that range at any moment, and they know
|
|
109
|
+
nothing about guards. Linux, macOS and BSD report the range, Windows uses a fixed
|
|
110
|
+
assumption (see [Platform support](#platform-support)). On Linux, ports listed in
|
|
111
|
+
`ip_local_reserved_ports` are skipped too.
|
|
112
|
+
|
|
113
|
+
## Guarantees
|
|
114
|
+
|
|
115
|
+
| Scenario | Protected? |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| Tests in one `pytest-xdist` run | Yes |
|
|
118
|
+
| Separate pytest runs in one network namespace | Yes |
|
|
119
|
+
| Pytest runs in separate network namespaces publishing ports to one host | No, see below |
|
|
120
|
+
| Outgoing connections from anything on the machine | Yes, leased ports are outside the ephemeral range |
|
|
121
|
+
| Server restarting during a test | Yes, nobody else gets the port |
|
|
122
|
+
| Crashed or killed pytest process | Yes, the kernel releases the port |
|
|
123
|
+
| Software using a fixed port in the same range | No, the port is only checked when it is leased |
|
|
124
|
+
| Port allocation by other tools | No, every allocator has to use guards |
|
|
125
|
+
|
|
126
|
+
The port is free on TCP and UDP, on IPv4 and IPv6, and the first bind works
|
|
127
|
+
without `SO_REUSEADDR`. A server that restarts needs `SO_REUSEADDR` as usual,
|
|
128
|
+
because its old connections can keep the port in `TIME_WAIT`. Every lease uses two
|
|
129
|
+
port numbers.
|
|
130
|
+
|
|
131
|
+
A process created with `fork()` without `exec()` inherits the guard, and the
|
|
132
|
+
port stays blocked until that process ends too. This can last for a whole test
|
|
133
|
+
session, but can't cause a collision.
|
|
134
|
+
|
|
135
|
+
Containers with their own network stack have their own ports, so tests running
|
|
136
|
+
inside them don't collide. That changes once they publish ports to a shared host,
|
|
137
|
+
for example when pytest runs in a container and starts other containers with
|
|
138
|
+
`docker run -p PORT:80` through a shared Docker socket. The guard is then in a
|
|
139
|
+
different namespace than the published port, so two such runs can get the same
|
|
140
|
+
port. To avoid this, run pytest with `--network host`, give every run its own
|
|
141
|
+
`TWINPORT_RANGE`, or let Docker choose the port with `-p 80` and read it with
|
|
142
|
+
`docker port`.
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
Ports are chosen from 15000-65535, minus the ephemeral range. To change this:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
export TWINPORT_RANGE=20000-30000
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`lease_port(port_range=(20000, 30000))` does the same for a single lease.
|
|
153
|
+
Ephemeral ports are left out in both cases. If no pair can be leased,
|
|
154
|
+
`NoFreePortError` is raised. This also happens when the ephemeral range of the
|
|
155
|
+
system covers nearly all ports, and then the only fix is to make it smaller.
|
|
156
|
+
|
|
157
|
+
## Measurements
|
|
158
|
+
|
|
159
|
+
`scripts/stress.py` runs parallel processes that repeatedly get a port, wait up
|
|
160
|
+
to 2 ms like a starting server, then bind the port without `SO_REUSEADDR`. It
|
|
161
|
+
counts failed binds. These are the results of three runs. Each run used 16
|
|
162
|
+
processes, 8000 allocations in total and a range of 200 ports (Linux, 6 cores):
|
|
163
|
+
|
|
164
|
+
| Method | Collisions |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `lease_port()` | 0, 0, 0 |
|
|
167
|
+
| Random port checked by bind and close | 15, 16, 15 |
|
|
168
|
+
| `bind(0)` and close | 1, 0, 0 |
|
|
169
|
+
|
|
170
|
+
`bind(0)` is not limited to the small range, so it collides less often in this
|
|
171
|
+
test. An additional run of `lease_port()` with 32 processes and 64000
|
|
172
|
+
allocations had 0 collisions too.
|
|
173
|
+
|
|
174
|
+
## Platform support
|
|
175
|
+
|
|
176
|
+
The tests pass on Linux, macOS and Windows. BSD systems have not been tried out.
|
|
177
|
+
|
|
178
|
+
There are two limits on Windows:
|
|
179
|
+
|
|
180
|
+
- A bind to a wildcard address does not conflict with a bind to a specific
|
|
181
|
+
address. Ports are checked on wildcard and loopback addresses, so a service
|
|
182
|
+
listening only on another address of the machine is not detected.
|
|
183
|
+
- The ephemeral range is not read from the system. Ports from 32768 up are left
|
|
184
|
+
out, which covers the default range. If the range was changed with `netsh` to
|
|
185
|
+
start lower, set `TWINPORT_RANGE` to stay out of it.
|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
uv sync
|
|
191
|
+
uv run pytest
|
|
192
|
+
uv run ruff check
|
|
193
|
+
uv run ruff format
|
|
194
|
+
uv run mypy
|
|
195
|
+
uv run scripts/stress.py
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Releasing
|
|
199
|
+
|
|
200
|
+
Set the new version in `pyproject.toml` and commit it. Publishing a GitHub
|
|
201
|
+
release then runs the tests, builds the package and uploads it to PyPI. The
|
|
202
|
+
release fails if its tag does not match the version:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
gh release create v0.1.0 --generate-notes
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Related projects
|
|
209
|
+
|
|
210
|
+
These get a free port for a moment and don't reserve it, so they have the race
|
|
211
|
+
condition described above:
|
|
212
|
+
|
|
213
|
+
- [pytest-asyncio](https://pypi.org/project/pytest-asyncio/) has the
|
|
214
|
+
`unused_tcp_port` and `unused_udp_port` fixtures and their factories, based on
|
|
215
|
+
`bind(0)` and close.
|
|
216
|
+
- [anyio](https://pypi.org/project/anyio/) has the `free_tcp_port` and
|
|
217
|
+
`free_udp_port` fixtures and their factories. They work the same way, and the
|
|
218
|
+
TCP and UDP ports are checked on both IPv4 and IPv6.
|
|
219
|
+
- [pytest-unused-port](https://pypi.org/project/pytest-unused-port/) has an
|
|
220
|
+
`unused_port` fixture based on `bind(0)` and close.
|
|
221
|
+
- [pytest-server-fixtures](https://pypi.org/project/pytest-server-fixtures/)
|
|
222
|
+
picks a random port below the usual ephemeral range, on a random loopback
|
|
223
|
+
address.
|
|
224
|
+
|
|
225
|
+
These coordinate between processes in other ways:
|
|
226
|
+
|
|
227
|
+
- [portpicker](https://pypi.org/project/portpicker/) by Google, which inspired
|
|
228
|
+
this project, solves the race with an optional `portserver` daemon.
|
|
229
|
+
`pytest-twinport` is not an integration of that library.
|
|
230
|
+
- [pytest-services](https://pypi.org/project/pytest-services/) has a
|
|
231
|
+
`port_getter` fixture that keeps the list of used ports in a shared lock file.
|
|
232
|
+
- [pytest-postgresql](https://pypi.org/project/pytest-postgresql/) marks its
|
|
233
|
+
port with a file in the temporary directory of the test run, which coordinates
|
|
234
|
+
`pytest-xdist` workers.
|
|
235
|
+
- [portkeeper](https://pypi.org/project/portkeeper/) keeps reservations in a JSON
|
|
236
|
+
registry protected by a file lock.
|
|
237
|
+
|
|
238
|
+
These solve neighboring problems:
|
|
239
|
+
|
|
240
|
+
- [port-for](https://pypi.org/project/port-for/) manages persistent port
|
|
241
|
+
assignments. It avoids the ephemeral range as well.
|
|
242
|
+
- [ephemeral-port-reserve](https://pypi.org/project/ephemeral-port-reserve/)
|
|
243
|
+
forces an ephemeral port into the `TIME_WAIT` state before releasing it.
|
|
244
|
+
- [portlock](https://pypi.org/project/portlock/) uses a bound port to make sure
|
|
245
|
+
that only one instance of a script is running.
|
|
246
|
+
- [pytest-localserver](https://pypi.org/project/pytest-localserver/) provides
|
|
247
|
+
test servers that bind to port 0 themselves, so they don't need a free port up
|
|
248
|
+
front.
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
MIT
|