edgelens-pytest 0.2.1__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.
- edgelens_pytest-0.2.1/.gitignore +9 -0
- edgelens_pytest-0.2.1/LICENSE +21 -0
- edgelens_pytest-0.2.1/PKG-INFO +178 -0
- edgelens_pytest-0.2.1/README.md +154 -0
- edgelens_pytest-0.2.1/pyproject.toml +56 -0
- edgelens_pytest-0.2.1/pytest_edgelens/__init__.py +7 -0
- edgelens_pytest-0.2.1/pytest_edgelens/lifecycle.py +135 -0
- edgelens_pytest-0.2.1/pytest_edgelens/plugin.py +307 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EdgeLens
|
|
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,178 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: edgelens-pytest
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: pytest plugin for EdgeLens — per-session/per-test camera fixtures, CLI options, JUnit metadata.
|
|
5
|
+
Project-URL: Homepage, https://edgelens.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/edgelens-ai/edgelens-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/edgelens-ai/edgelens-sdk/issues
|
|
8
|
+
Author-email: EdgeLens <support@edgelens.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: edgelens,pytest,pytest-plugin,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Requires-Dist: edgelens-sdk<0.3,>=0.2.1
|
|
19
|
+
Requires-Dist: pytest>=7.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# edgelens-pytest
|
|
26
|
+
|
|
27
|
+
pytest plugin for the EdgeLens Edge AI Device Farm — write a test that
|
|
28
|
+
gets a real edge AI camera as a fixture, with auto-allocate, auto-
|
|
29
|
+
release, configurable scope, OS filtering, and JUnit metadata.
|
|
30
|
+
|
|
31
|
+
Counterpart to [`@edgelens/playwright-plugin`](../playwright) for
|
|
32
|
+
the Python world.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install edgelens-pytest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Python 3.9+. Auto-loaded by pytest via the `pytest11` entry
|
|
41
|
+
point — no `conftest.py` glue needed.
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# tests/test_camera.py
|
|
47
|
+
def test_inference_latency(device):
|
|
48
|
+
device.deploy(model="./face.cvimodel")
|
|
49
|
+
device.inference.start()
|
|
50
|
+
stats = device.inference.run(duration=30)
|
|
51
|
+
assert stats.fps.avg > 15
|
|
52
|
+
assert stats.latency_ms.p95 < 100
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Run it:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
export EDGELENS_API_KEY=el_test_xxx
|
|
59
|
+
pytest
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`device` is **session-scoped** by default — one camera for the whole
|
|
63
|
+
run. Use `--edgelens-scope=module` or `--edgelens-scope=function` if
|
|
64
|
+
tests mutate device state in ways the next test would inherit broken.
|
|
65
|
+
|
|
66
|
+
## Fixtures
|
|
67
|
+
|
|
68
|
+
| Fixture | Scope | What |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `device` | session/module/function (CLI) | `edgelens.Device` ready to use |
|
|
71
|
+
| `edgelens_session` | same as `device` | `edgelens.Session`, already past `wait_ready()` |
|
|
72
|
+
| `edgelens_client` | session | `edgelens.EdgeLensClient` |
|
|
73
|
+
| `edgelens_options` | session | `EdgelensOptions` derived from CLI + env |
|
|
74
|
+
|
|
75
|
+
## CLI options
|
|
76
|
+
|
|
77
|
+
| Flag | Default | Purpose |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `--edgelens-key` | — | API key. Prefer `$EDGELENS_API_KEY`: a value on the command line is visible in the process list and shell history. |
|
|
80
|
+
| `--edgelens-base-url` | — | Self-hosted EdgeLens deployments. |
|
|
81
|
+
| `--edgelens-os` | `stock-os` | OS to flash. Alt: `recamagined`. |
|
|
82
|
+
| `--edgelens-device-type` | `recamera-2002` | Device type to allocate. |
|
|
83
|
+
| `--edgelens-scope` | `session` | `session` / `module` / `function`. |
|
|
84
|
+
| `--edgelens-session-timeout` | `3600` | Session duration cap (s). |
|
|
85
|
+
| `--edgelens-skip-allocation` | off | Fixtures yield `None`. For plugin-self-testing. |
|
|
86
|
+
|
|
87
|
+
`wait_ready` does not need a client-side timeout: the platform
|
|
88
|
+
signals a readiness deadline on the session response and the
|
|
89
|
+
SDK trusts it. `--edgelens-wait-timeout` is a deprecated
|
|
90
|
+
escape hatch; setting it emits a `PytestDeprecationWarning`.
|
|
91
|
+
|
|
92
|
+
## OS marker
|
|
93
|
+
|
|
94
|
+
Pin a test to a specific OS via `@pytest.mark.edgelens(os=...)`:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import pytest
|
|
98
|
+
|
|
99
|
+
@pytest.mark.edgelens(os="recamagined")
|
|
100
|
+
def test_recamagined_only(device):
|
|
101
|
+
...
|
|
102
|
+
|
|
103
|
+
@pytest.mark.edgelens(os="stock-os")
|
|
104
|
+
def test_stock_only(device):
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
def test_works_on_either(device):
|
|
108
|
+
...
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Run once per OS:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
pytest --edgelens-os=stock-os
|
|
115
|
+
pytest --edgelens-os=recamagined
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Marker mismatches surface as `pytest.mark.skip` with a clear reason, so
|
|
119
|
+
the report tells you why a test was excluded.
|
|
120
|
+
|
|
121
|
+
The same pattern works for `device_type`:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
@pytest.mark.edgelens(device_type="some-other-device")
|
|
125
|
+
def test_only_for_other(device):
|
|
126
|
+
...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Multi-OS coverage
|
|
130
|
+
|
|
131
|
+
The fixture is session-scoped by default — one camera for the run.
|
|
132
|
+
Mixing OS variants under a single scope would require multiple sessions,
|
|
133
|
+
which we deliberately don't support implicitly (allocations cost money
|
|
134
|
+
+ wall-clock time).
|
|
135
|
+
|
|
136
|
+
To cover multiple OS variants in CI, run pytest once per OS:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
# .github/workflows/edgelens-matrix.yml
|
|
140
|
+
strategy:
|
|
141
|
+
matrix:
|
|
142
|
+
os: [stock, recamagined]
|
|
143
|
+
steps:
|
|
144
|
+
- uses: actions/checkout@v4
|
|
145
|
+
- uses: edgelens-ai/edgelens-sdk/ci/github-action@v0.2.1
|
|
146
|
+
with:
|
|
147
|
+
api-key: ${{ secrets.EDGELENS_API_KEY }}
|
|
148
|
+
command: pytest --edgelens-os=${{ matrix.os }} --junit-xml=junit.xml
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
For fine-grained per-test OS switching inside a single run, drop the
|
|
152
|
+
fixture scope to `function` and add a custom `os_variant` parametrize
|
|
153
|
+
in your own `conftest.py` (see the SDK example projects).
|
|
154
|
+
|
|
155
|
+
## JUnit metadata
|
|
156
|
+
|
|
157
|
+
Each test's testcase in the JUnit XML gets these extra `<property>`
|
|
158
|
+
entries:
|
|
159
|
+
|
|
160
|
+
- `edgelens_session_id`
|
|
161
|
+
- `edgelens_mac`
|
|
162
|
+
- `edgelens_host`
|
|
163
|
+
- `edgelens_os`
|
|
164
|
+
|
|
165
|
+
So the GitLab MR widget / GitHub Action summary / CI dashboard can show
|
|
166
|
+
*which camera ran which test* without parsing logs.
|
|
167
|
+
|
|
168
|
+
## Develop
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
python -m venv .venv && source .venv/bin/activate
|
|
172
|
+
pip install -e ".[dev]"
|
|
173
|
+
pytest
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## See also
|
|
177
|
+
|
|
178
|
+
Pairs with `@edgelens/playwright-plugin` for JS/TS teams.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# edgelens-pytest
|
|
2
|
+
|
|
3
|
+
pytest plugin for the EdgeLens Edge AI Device Farm — write a test that
|
|
4
|
+
gets a real edge AI camera as a fixture, with auto-allocate, auto-
|
|
5
|
+
release, configurable scope, OS filtering, and JUnit metadata.
|
|
6
|
+
|
|
7
|
+
Counterpart to [`@edgelens/playwright-plugin`](../playwright) for
|
|
8
|
+
the Python world.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pip install edgelens-pytest
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Python 3.9+. Auto-loaded by pytest via the `pytest11` entry
|
|
17
|
+
point — no `conftest.py` glue needed.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
# tests/test_camera.py
|
|
23
|
+
def test_inference_latency(device):
|
|
24
|
+
device.deploy(model="./face.cvimodel")
|
|
25
|
+
device.inference.start()
|
|
26
|
+
stats = device.inference.run(duration=30)
|
|
27
|
+
assert stats.fps.avg > 15
|
|
28
|
+
assert stats.latency_ms.p95 < 100
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Run it:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
export EDGELENS_API_KEY=el_test_xxx
|
|
35
|
+
pytest
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`device` is **session-scoped** by default — one camera for the whole
|
|
39
|
+
run. Use `--edgelens-scope=module` or `--edgelens-scope=function` if
|
|
40
|
+
tests mutate device state in ways the next test would inherit broken.
|
|
41
|
+
|
|
42
|
+
## Fixtures
|
|
43
|
+
|
|
44
|
+
| Fixture | Scope | What |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `device` | session/module/function (CLI) | `edgelens.Device` ready to use |
|
|
47
|
+
| `edgelens_session` | same as `device` | `edgelens.Session`, already past `wait_ready()` |
|
|
48
|
+
| `edgelens_client` | session | `edgelens.EdgeLensClient` |
|
|
49
|
+
| `edgelens_options` | session | `EdgelensOptions` derived from CLI + env |
|
|
50
|
+
|
|
51
|
+
## CLI options
|
|
52
|
+
|
|
53
|
+
| Flag | Default | Purpose |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `--edgelens-key` | — | API key. Prefer `$EDGELENS_API_KEY`: a value on the command line is visible in the process list and shell history. |
|
|
56
|
+
| `--edgelens-base-url` | — | Self-hosted EdgeLens deployments. |
|
|
57
|
+
| `--edgelens-os` | `stock-os` | OS to flash. Alt: `recamagined`. |
|
|
58
|
+
| `--edgelens-device-type` | `recamera-2002` | Device type to allocate. |
|
|
59
|
+
| `--edgelens-scope` | `session` | `session` / `module` / `function`. |
|
|
60
|
+
| `--edgelens-session-timeout` | `3600` | Session duration cap (s). |
|
|
61
|
+
| `--edgelens-skip-allocation` | off | Fixtures yield `None`. For plugin-self-testing. |
|
|
62
|
+
|
|
63
|
+
`wait_ready` does not need a client-side timeout: the platform
|
|
64
|
+
signals a readiness deadline on the session response and the
|
|
65
|
+
SDK trusts it. `--edgelens-wait-timeout` is a deprecated
|
|
66
|
+
escape hatch; setting it emits a `PytestDeprecationWarning`.
|
|
67
|
+
|
|
68
|
+
## OS marker
|
|
69
|
+
|
|
70
|
+
Pin a test to a specific OS via `@pytest.mark.edgelens(os=...)`:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import pytest
|
|
74
|
+
|
|
75
|
+
@pytest.mark.edgelens(os="recamagined")
|
|
76
|
+
def test_recamagined_only(device):
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
@pytest.mark.edgelens(os="stock-os")
|
|
80
|
+
def test_stock_only(device):
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def test_works_on_either(device):
|
|
84
|
+
...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Run once per OS:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
pytest --edgelens-os=stock-os
|
|
91
|
+
pytest --edgelens-os=recamagined
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Marker mismatches surface as `pytest.mark.skip` with a clear reason, so
|
|
95
|
+
the report tells you why a test was excluded.
|
|
96
|
+
|
|
97
|
+
The same pattern works for `device_type`:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
@pytest.mark.edgelens(device_type="some-other-device")
|
|
101
|
+
def test_only_for_other(device):
|
|
102
|
+
...
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Multi-OS coverage
|
|
106
|
+
|
|
107
|
+
The fixture is session-scoped by default — one camera for the run.
|
|
108
|
+
Mixing OS variants under a single scope would require multiple sessions,
|
|
109
|
+
which we deliberately don't support implicitly (allocations cost money
|
|
110
|
+
+ wall-clock time).
|
|
111
|
+
|
|
112
|
+
To cover multiple OS variants in CI, run pytest once per OS:
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
# .github/workflows/edgelens-matrix.yml
|
|
116
|
+
strategy:
|
|
117
|
+
matrix:
|
|
118
|
+
os: [stock, recamagined]
|
|
119
|
+
steps:
|
|
120
|
+
- uses: actions/checkout@v4
|
|
121
|
+
- uses: edgelens-ai/edgelens-sdk/ci/github-action@v0.2.1
|
|
122
|
+
with:
|
|
123
|
+
api-key: ${{ secrets.EDGELENS_API_KEY }}
|
|
124
|
+
command: pytest --edgelens-os=${{ matrix.os }} --junit-xml=junit.xml
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
For fine-grained per-test OS switching inside a single run, drop the
|
|
128
|
+
fixture scope to `function` and add a custom `os_variant` parametrize
|
|
129
|
+
in your own `conftest.py` (see the SDK example projects).
|
|
130
|
+
|
|
131
|
+
## JUnit metadata
|
|
132
|
+
|
|
133
|
+
Each test's testcase in the JUnit XML gets these extra `<property>`
|
|
134
|
+
entries:
|
|
135
|
+
|
|
136
|
+
- `edgelens_session_id`
|
|
137
|
+
- `edgelens_mac`
|
|
138
|
+
- `edgelens_host`
|
|
139
|
+
- `edgelens_os`
|
|
140
|
+
|
|
141
|
+
So the GitLab MR widget / GitHub Action summary / CI dashboard can show
|
|
142
|
+
*which camera ran which test* without parsing logs.
|
|
143
|
+
|
|
144
|
+
## Develop
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
python -m venv .venv && source .venv/bin/activate
|
|
148
|
+
pip install -e ".[dev]"
|
|
149
|
+
pytest
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## See also
|
|
153
|
+
|
|
154
|
+
Pairs with `@edgelens/playwright-plugin` for JS/TS teams.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "edgelens-pytest"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "pytest plugin for EdgeLens — per-session/per-test camera fixtures, CLI options, JUnit metadata."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "EdgeLens", email = "support@edgelens.ai" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
keywords = ["pytest", "pytest-plugin", "edgelens", "testing"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Framework :: Pytest",
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"edgelens-sdk>=0.2.1,<0.3",
|
|
24
|
+
"pytest>=7.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"ruff>=0.1",
|
|
30
|
+
"mypy>=1.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.entry-points.pytest11]
|
|
34
|
+
pytest_edgelens = "pytest_edgelens.plugin"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://edgelens.ai"
|
|
38
|
+
Repository = "https://github.com/edgelens-ai/edgelens-sdk"
|
|
39
|
+
Issues = "https://github.com/edgelens-ai/edgelens-sdk/issues"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
include = ["/pytest_edgelens", "/README.md", "/LICENSE"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["pytest_edgelens"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py39"
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
strict = true
|
|
53
|
+
python_version = "3.9"
|
|
54
|
+
|
|
55
|
+
[tool.hatch.version]
|
|
56
|
+
path = "pytest_edgelens/__init__.py"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Fixture lifecycle helpers — the actual session allocation + release
|
|
2
|
+
logic, kept separate from ``plugin.py`` so it can be unit-tested
|
|
3
|
+
without pytester.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field, fields
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from edgelens import EdgeLensClient, Session
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fingerprint(api_key: Optional[str]) -> str:
|
|
19
|
+
"""Render a key so two different keys look different and neither is readable.
|
|
20
|
+
|
|
21
|
+
A prefix would do the first but not the second: a prefix of a
|
|
22
|
+
secret is a piece of the secret, which is why the platform's own
|
|
23
|
+
truncated key display is not reusable here. Six hex characters of
|
|
24
|
+
sha256 identify a key to someone who already has it and give
|
|
25
|
+
nothing to someone who does not.
|
|
26
|
+
"""
|
|
27
|
+
if api_key is None:
|
|
28
|
+
return "None"
|
|
29
|
+
return f"<set sha256:{hashlib.sha256(api_key.encode()).hexdigest()[:6]}>"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class EdgelensOptions:
|
|
34
|
+
api_key: Optional[str] = field(default=None, repr=False)
|
|
35
|
+
base_url: Optional[str] = None
|
|
36
|
+
device_type: str = "recamera-2002"
|
|
37
|
+
os: str = "stock-os"
|
|
38
|
+
image_version: Optional[str] = None
|
|
39
|
+
session_timeout_s: int = 3600
|
|
40
|
+
# None (default) trusts the server's `deadline` field;
|
|
41
|
+
# an explicit int is a caller-supplied ceiling override.
|
|
42
|
+
wait_ready_timeout_s: Optional[int] = None
|
|
43
|
+
poll_interval_s: float = 2.0
|
|
44
|
+
skip_allocation: bool = False
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
# pytest renders fixture arguments when setup raises, so this
|
|
48
|
+
# is what reaches a customer's CI log on a failed allocation.
|
|
49
|
+
shown = [f"api_key={_fingerprint(self.api_key)}"]
|
|
50
|
+
shown += [
|
|
51
|
+
f"{f.name}={getattr(self, f.name)!r}" for f in fields(self) if f.repr
|
|
52
|
+
]
|
|
53
|
+
return f"{type(self).__name__}({', '.join(shown)})"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_client(opts: EdgelensOptions) -> EdgeLensClient:
|
|
57
|
+
"""Construct the underlying EdgeLensClient.
|
|
58
|
+
|
|
59
|
+
Reads the API key from the options or falls through to the SDK's
|
|
60
|
+
own env-var fallback (``EDGELENS_API_KEY``). The SDK raises
|
|
61
|
+
``AuthenticationError`` when neither is set; we let it propagate
|
|
62
|
+
so pytest reports it as a fixture-setup failure with the right
|
|
63
|
+
pointer.
|
|
64
|
+
"""
|
|
65
|
+
kwargs: dict[str, Any] = {}
|
|
66
|
+
if opts.api_key:
|
|
67
|
+
kwargs["api_key"] = opts.api_key
|
|
68
|
+
if opts.base_url:
|
|
69
|
+
kwargs["base_url"] = opts.base_url
|
|
70
|
+
return EdgeLensClient(**kwargs)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def allocate_session(client: EdgeLensClient, opts: EdgelensOptions) -> Session:
|
|
74
|
+
"""Create a session and wait for ready.
|
|
75
|
+
|
|
76
|
+
Errors from create_session propagate directly. Anything raised by
|
|
77
|
+
wait_ready triggers a synchronous ``session.release()`` before
|
|
78
|
+
re-raising — otherwise the api-side session is orphaned (the
|
|
79
|
+
fixture teardown only sees the return value, and there is no
|
|
80
|
+
return value on the exceptional path) and it leaks until the
|
|
81
|
+
server-side timeout (~1h), blocking the next allocation with
|
|
82
|
+
``max_concurrent_sessions`` violations.
|
|
83
|
+
|
|
84
|
+
``BaseException`` and not ``Exception``, because the likeliest way
|
|
85
|
+
this wait ever ends early is someone pressing Ctrl-C, and
|
|
86
|
+
``KeyboardInterrupt`` is not an ``Exception``. The wait prints
|
|
87
|
+
nothing and can run for half an hour, so an operator watching a
|
|
88
|
+
silent terminal interrupts it long before it times out. A guard
|
|
89
|
+
that caught only ``Exception`` would miss the most likely trigger
|
|
90
|
+
and leak the session.
|
|
91
|
+
|
|
92
|
+
A plain ``try``/``finally`` would be wrong here: this returns the
|
|
93
|
+
live session on the success path, and a ``finally`` would release
|
|
94
|
+
it the moment it became ready.
|
|
95
|
+
"""
|
|
96
|
+
session = client.create_session(
|
|
97
|
+
device_type=opts.device_type,
|
|
98
|
+
os=opts.os,
|
|
99
|
+
timeout=opts.session_timeout_s,
|
|
100
|
+
image_version=opts.image_version,
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
session.wait_ready(
|
|
104
|
+
timeout=opts.wait_ready_timeout_s,
|
|
105
|
+
poll_interval=opts.poll_interval_s,
|
|
106
|
+
)
|
|
107
|
+
except BaseException:
|
|
108
|
+
try:
|
|
109
|
+
session.release()
|
|
110
|
+
# Deliberately narrower than the guard above. A second
|
|
111
|
+
# interrupt arriving while the release is in flight belongs to
|
|
112
|
+
# the operator insisting, and must not be turned into a log
|
|
113
|
+
# line — there is nothing left to protect by then anyway.
|
|
114
|
+
except Exception as release_exc: # noqa: BLE001
|
|
115
|
+
logger.warning(
|
|
116
|
+
"edgelens session release failed during allocate_session "
|
|
117
|
+
"cleanup: %s (primary error follows)", release_exc,
|
|
118
|
+
)
|
|
119
|
+
raise
|
|
120
|
+
return session
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def release_session(session: Optional[Session]) -> None:
|
|
124
|
+
"""Release a session, swallowing teardown errors.
|
|
125
|
+
|
|
126
|
+
A failed release shouldn't mask the actual test failure that
|
|
127
|
+
happened before it — pytest already records the test outcome by
|
|
128
|
+
the time fixture teardown runs.
|
|
129
|
+
"""
|
|
130
|
+
if session is None:
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
session.release()
|
|
134
|
+
except Exception as e: # noqa: BLE001
|
|
135
|
+
logger.warning("edgelens session release failed: %s", e)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""pytest_edgelens plugin module.
|
|
2
|
+
|
|
3
|
+
Adds CLI options, fixtures, and JUnit metadata for tests that need a
|
|
4
|
+
real EdgeLens camera. Auto-loaded via the ``pytest11`` entry point.
|
|
5
|
+
|
|
6
|
+
Fixtures
|
|
7
|
+
--------
|
|
8
|
+
- ``edgelens_options`` — CLI/env-derived ``EdgelensOptions`` (session)
|
|
9
|
+
- ``edgelens_client`` — single ``EdgeLensClient`` for the run (session)
|
|
10
|
+
- ``edgelens_session`` — allocated ``Session``, scope per ``--edgelens-scope``
|
|
11
|
+
- ``device`` — the ``Device`` from the session (same scope)
|
|
12
|
+
|
|
13
|
+
Markers
|
|
14
|
+
-------
|
|
15
|
+
- ``@pytest.mark.edgelens(os="recamagined")`` — skip the test unless
|
|
16
|
+
the active ``--edgelens-os`` matches the marker. Pairs with running
|
|
17
|
+
pytest once per OS instead of mixing scopes.
|
|
18
|
+
|
|
19
|
+
CLI options
|
|
20
|
+
-----------
|
|
21
|
+
- ``--edgelens-key`` (env ``EDGELENS_API_KEY``)
|
|
22
|
+
- ``--edgelens-base-url``
|
|
23
|
+
- ``--edgelens-os`` (default ``stock-os``)
|
|
24
|
+
- ``--edgelens-device-type`` (default ``recamera-2002``)
|
|
25
|
+
- ``--edgelens-image-version`` (default ``None`` — platform picks)
|
|
26
|
+
- ``--edgelens-scope`` (``session``/``module``/``function``, default ``session``)
|
|
27
|
+
- ``--edgelens-session-timeout`` (seconds, default 3600)
|
|
28
|
+
- ``--edgelens-wait-timeout`` (seconds; **DEPRECATED** —
|
|
29
|
+
wait_ready is now server-driven via the server's ``deadline`` field. The
|
|
30
|
+
flag remains for one release as an explicit ceiling override, and
|
|
31
|
+
emits a ``PytestDeprecationWarning`` when set.)
|
|
32
|
+
- ``--edgelens-skip-allocation`` — skip any test that requests
|
|
33
|
+
``edgelens_session``/``device`` (no platform call), for testing the
|
|
34
|
+
plugin itself and collect-only runs.
|
|
35
|
+
|
|
36
|
+
JUnit metadata
|
|
37
|
+
--------------
|
|
38
|
+
On each test, the session id, device mac, host, os, and device-type are
|
|
39
|
+
recorded as ``<property>`` entries so JUnit consumers (GitLab MR
|
|
40
|
+
widget, GitHub Action summary, etc.) can show which device a test ran
|
|
41
|
+
on without parsing logs.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import os
|
|
47
|
+
from typing import Optional
|
|
48
|
+
|
|
49
|
+
import pytest
|
|
50
|
+
|
|
51
|
+
from pytest_edgelens import lifecycle
|
|
52
|
+
from pytest_edgelens.lifecycle import EdgelensOptions
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
SCOPES = ("session", "module", "function")
|
|
56
|
+
DEFAULT_SCOPE = "session"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---- CLI options + marker registration --------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
63
|
+
group = parser.getgroup("edgelens", "EdgeLens device-farm options")
|
|
64
|
+
group.addoption(
|
|
65
|
+
"--edgelens-key", default=None,
|
|
66
|
+
help=(
|
|
67
|
+
"EdgeLens API key. Prefer EDGELENS_API_KEY: a key on the "
|
|
68
|
+
"command line is visible in the process list and CI logs."
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
group.addoption(
|
|
72
|
+
"--edgelens-base-url", default=None,
|
|
73
|
+
help="Override the platform API base URL.",
|
|
74
|
+
)
|
|
75
|
+
group.addoption(
|
|
76
|
+
"--edgelens-os", default="stock-os",
|
|
77
|
+
help="OS to flash (default: stock-os; alt: recamagined).",
|
|
78
|
+
)
|
|
79
|
+
group.addoption(
|
|
80
|
+
"--edgelens-device-type", default="recamera-2002",
|
|
81
|
+
help="Device type to allocate (default: recamera-2002).",
|
|
82
|
+
)
|
|
83
|
+
group.addoption(
|
|
84
|
+
"--edgelens-image-version", default=None,
|
|
85
|
+
help=(
|
|
86
|
+
"OS image version to flash. Omit to let the platform pick "
|
|
87
|
+
"its default for --edgelens-os."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
group.addoption(
|
|
91
|
+
"--edgelens-scope", default=DEFAULT_SCOPE, choices=SCOPES,
|
|
92
|
+
help=(
|
|
93
|
+
"Fixture scope for `edgelens_session` and `device`. "
|
|
94
|
+
"Default: session (cheapest — one camera for the whole run). "
|
|
95
|
+
"Use module/function for fresh-per-module/test when tests mutate "
|
|
96
|
+
"device state in ways the previous test would leave broken."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
group.addoption(
|
|
100
|
+
"--edgelens-session-timeout", default=3600, type=int,
|
|
101
|
+
help="Max session duration in seconds (default: 3600).",
|
|
102
|
+
)
|
|
103
|
+
group.addoption(
|
|
104
|
+
"--edgelens-wait-timeout", default=None, type=int,
|
|
105
|
+
help=(
|
|
106
|
+
"DEPRECATED: wait_ready is now server-driven via "
|
|
107
|
+
"the server's `deadline` field — the platform commits to a "
|
|
108
|
+
"readiness deadline and the SDK trusts it. This flag "
|
|
109
|
+
"remains for one release as an explicit ceiling override "
|
|
110
|
+
"(shortens the wait; never extends past the server "
|
|
111
|
+
"deadline). Emits PytestDeprecationWarning when set."
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
group.addoption(
|
|
115
|
+
"--edgelens-skip-allocation", action="store_true", default=False,
|
|
116
|
+
help=(
|
|
117
|
+
"Skip session allocation. Tests that request "
|
|
118
|
+
"`edgelens_session`/`device` are skipped (not passed a None "
|
|
119
|
+
"device). Useful for testing the plugin itself or collect-only "
|
|
120
|
+
"runs."
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
126
|
+
config.addinivalue_line(
|
|
127
|
+
"markers",
|
|
128
|
+
"edgelens(os=None, device_type=None): "
|
|
129
|
+
"constrain a test to a specific (os, device_type). The test is "
|
|
130
|
+
"skipped when --edgelens-os/--edgelens-device-type don't match.",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---- Options + client + session fixtures -----------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.fixture(scope="session")
|
|
138
|
+
def edgelens_options(request: pytest.FixtureRequest) -> EdgelensOptions:
|
|
139
|
+
cfg = request.config
|
|
140
|
+
api_key = cfg.getoption("--edgelens-key") or os.environ.get("EDGELENS_API_KEY")
|
|
141
|
+
wait_ready_timeout_s = cfg.getoption("--edgelens-wait-timeout")
|
|
142
|
+
if wait_ready_timeout_s is not None:
|
|
143
|
+
# warn when the deprecated flag is actually set. Absent
|
|
144
|
+
# (default None) means the SDK trusts the server-signaled
|
|
145
|
+
# deadline; no warning noise.
|
|
146
|
+
import warnings
|
|
147
|
+
warnings.warn(
|
|
148
|
+
"--edgelens-wait-timeout is deprecated — wait_ready "
|
|
149
|
+
"is now server-driven via the server's `deadline` field. Remove the "
|
|
150
|
+
"flag unless you specifically need to shorten the wait for "
|
|
151
|
+
"abort-early / testing.",
|
|
152
|
+
pytest.PytestDeprecationWarning,
|
|
153
|
+
stacklevel=2,
|
|
154
|
+
)
|
|
155
|
+
return EdgelensOptions(
|
|
156
|
+
api_key=api_key,
|
|
157
|
+
base_url=cfg.getoption("--edgelens-base-url"),
|
|
158
|
+
device_type=cfg.getoption("--edgelens-device-type"),
|
|
159
|
+
os=cfg.getoption("--edgelens-os"),
|
|
160
|
+
image_version=cfg.getoption("--edgelens-image-version"),
|
|
161
|
+
session_timeout_s=cfg.getoption("--edgelens-session-timeout"),
|
|
162
|
+
wait_ready_timeout_s=wait_ready_timeout_s,
|
|
163
|
+
skip_allocation=cfg.getoption("--edgelens-skip-allocation"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@pytest.fixture(scope="session")
|
|
168
|
+
def edgelens_client(edgelens_options: EdgelensOptions): # type: ignore[no-untyped-def]
|
|
169
|
+
if edgelens_options.skip_allocation:
|
|
170
|
+
yield None
|
|
171
|
+
return
|
|
172
|
+
client = lifecycle.build_client(edgelens_options)
|
|
173
|
+
try:
|
|
174
|
+
yield client
|
|
175
|
+
finally:
|
|
176
|
+
client.close()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _dynamic_scope(*, fixture_name: str, config: pytest.Config) -> str:
|
|
180
|
+
del fixture_name # unused
|
|
181
|
+
return str(config.getoption("--edgelens-scope") or DEFAULT_SCOPE)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.fixture(scope=_dynamic_scope) # type: ignore[arg-type]
|
|
185
|
+
def edgelens_session(
|
|
186
|
+
edgelens_client, edgelens_options: EdgelensOptions
|
|
187
|
+
): # type: ignore[no-untyped-def]
|
|
188
|
+
if edgelens_options.skip_allocation or edgelens_client is None:
|
|
189
|
+
# Skip — don't hand back a None session that the test would
|
|
190
|
+
# then dereference into an AttributeError.
|
|
191
|
+
pytest.skip("edgelens allocation disabled (--edgelens-skip-allocation)")
|
|
192
|
+
session = lifecycle.allocate_session(edgelens_client, edgelens_options)
|
|
193
|
+
try:
|
|
194
|
+
yield session
|
|
195
|
+
finally:
|
|
196
|
+
lifecycle.release_session(session)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.fixture(scope=_dynamic_scope) # type: ignore[arg-type]
|
|
200
|
+
def device(edgelens_session): # type: ignore[no-untyped-def]
|
|
201
|
+
# edgelens_session skips under --edgelens-skip-allocation, so a test
|
|
202
|
+
# reaching here always has a real session.
|
|
203
|
+
return edgelens_session.device
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---- Marker handling: skip mismatched (os, device_type) --------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def pytest_collection_modifyitems(
|
|
210
|
+
config: pytest.Config, items: list[pytest.Item]
|
|
211
|
+
) -> None:
|
|
212
|
+
active_os = config.getoption("--edgelens-os")
|
|
213
|
+
active_device = config.getoption("--edgelens-device-type")
|
|
214
|
+
for item in items:
|
|
215
|
+
marker = item.get_closest_marker("edgelens")
|
|
216
|
+
if marker is None:
|
|
217
|
+
continue
|
|
218
|
+
required_os = marker.kwargs.get("os")
|
|
219
|
+
required_device = marker.kwargs.get("device_type")
|
|
220
|
+
if required_os is not None and required_os != active_os:
|
|
221
|
+
item.add_marker(
|
|
222
|
+
pytest.mark.skip(
|
|
223
|
+
reason=(
|
|
224
|
+
f"requires --edgelens-os={required_os}, "
|
|
225
|
+
f"got {active_os}"
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
if required_device is not None and required_device != active_device:
|
|
230
|
+
item.add_marker(
|
|
231
|
+
pytest.mark.skip(
|
|
232
|
+
reason=(
|
|
233
|
+
f"requires --edgelens-device-type={required_device}, "
|
|
234
|
+
f"got {active_device}"
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---- JUnit metadata: stamp device + session info on each testcase -----------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
244
|
+
def pytest_runtest_makereport(
|
|
245
|
+
item: pytest.Item, call: pytest.CallInfo # type: ignore[type-arg]
|
|
246
|
+
): # type: ignore[no-untyped-def]
|
|
247
|
+
outcome = yield
|
|
248
|
+
if call.when != "call":
|
|
249
|
+
return
|
|
250
|
+
# Look up the session fixture without forcing it (avoid triggering
|
|
251
|
+
# allocation for tests that didn't ask for it).
|
|
252
|
+
request: Optional[pytest.FixtureRequest] = getattr(item, "_request", None)
|
|
253
|
+
if request is None:
|
|
254
|
+
return
|
|
255
|
+
cache = getattr(request, "_fixture_defs", None) or {}
|
|
256
|
+
fixturedef = cache.get("edgelens_session")
|
|
257
|
+
if fixturedef is None or fixturedef.cached_result is None:
|
|
258
|
+
return
|
|
259
|
+
session = fixturedef.cached_result[0]
|
|
260
|
+
if session is None:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
# Stash the live session on the terminal reporter so
|
|
264
|
+
# pytest_terminal_summary can print it without forcing a new
|
|
265
|
+
# allocation. Set here (only when a test actually used the session)
|
|
266
|
+
# rather than via an autouse fixture that would allocate on every run.
|
|
267
|
+
reporter = item.config.pluginmanager.getplugin("terminalreporter")
|
|
268
|
+
if reporter is not None:
|
|
269
|
+
reporter._edgelens_session = session # type: ignore[attr-defined]
|
|
270
|
+
|
|
271
|
+
report = outcome.get_result()
|
|
272
|
+
user_props = list(getattr(report, "user_properties", []) or [])
|
|
273
|
+
user_props.append(("edgelens_session_id", session.id))
|
|
274
|
+
conn = getattr(session, "_connection", None)
|
|
275
|
+
if conn is not None:
|
|
276
|
+
user_props.extend(
|
|
277
|
+
[
|
|
278
|
+
("edgelens_mac", conn.mac),
|
|
279
|
+
("edgelens_host", conn.host),
|
|
280
|
+
("edgelens_os", conn.os.value),
|
|
281
|
+
]
|
|
282
|
+
)
|
|
283
|
+
report.user_properties = user_props
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---- Terminal summary line -------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# The session shown here is stashed on the
|
|
290
|
+
# terminal reporter by pytest_runtest_makereport — only when a test
|
|
291
|
+
# actually used `edgelens_session`. No autouse fixture, so a
|
|
292
|
+
# --collect-only / plugin-only / no-device run never forces a PAID
|
|
293
|
+
# allocation just to print a summary.
|
|
294
|
+
def pytest_terminal_summary(terminalreporter, exitstatus, config): # type: ignore[no-untyped-def]
|
|
295
|
+
if config.getoption("--collect-only", default=False):
|
|
296
|
+
return
|
|
297
|
+
if config.getoption("--edgelens-skip-allocation", default=False):
|
|
298
|
+
return
|
|
299
|
+
# Only read the session captured during the run; never allocate here.
|
|
300
|
+
session = getattr(terminalreporter, "_edgelens_session", None)
|
|
301
|
+
if session is None:
|
|
302
|
+
return
|
|
303
|
+
terminalreporter.write_sep("-", "edgelens", bold=True)
|
|
304
|
+
terminalreporter.write_line(
|
|
305
|
+
f"session {session.id} ({config.getoption('--edgelens-device-type')} / "
|
|
306
|
+
f"{config.getoption('--edgelens-os')})"
|
|
307
|
+
)
|