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.
@@ -0,0 +1,9 @@
1
+ .venv
2
+ __pycache__
3
+ *.egg-info
4
+ dist
5
+ build
6
+ .pytest_cache
7
+ .mypy_cache
8
+ .ruff_cache
9
+ *.pyc
@@ -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,7 @@
1
+ """pytest plugin for the EdgeLens Edge AI Device Farm.
2
+
3
+ Auto-loaded by pytest via the ``pytest11`` entry point in
4
+ ``pyproject.toml``. No need to add anything to your ``conftest.py``.
5
+ """
6
+
7
+ __version__ = "0.2.1"
@@ -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
+ )