pytest-embedded-arduino-cli 0.2.0__py3-none-any.whl

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,6 @@
1
+ """pytest-embedded integration for Arduino CLI based projects."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.0"
6
+
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ import subprocess
6
+ import os
7
+ import tomllib
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+
13
+ class SketchConfigError(ValueError):
14
+ """Raised when the sketch directory or sketch.yaml is invalid."""
15
+
16
+
17
+ def resolve_test_path(raw_path: str | Path) -> Path:
18
+ path = Path(raw_path).resolve()
19
+ if path.is_dir():
20
+ return path
21
+ return path.parent
22
+
23
+
24
+ def resolve_sketch_dir(test_file_or_dir: str | Path) -> Path:
25
+ sketch_dir = resolve_test_path(test_file_or_dir)
26
+ ino_files = sorted(sketch_dir.glob("*.ino"))
27
+ if not ino_files:
28
+ raise SketchConfigError(f"no .ino file found in sketch directory: {sketch_dir}")
29
+ if len(ino_files) > 1:
30
+ raise SketchConfigError(
31
+ f"multiple .ino files found in sketch directory: {sketch_dir}. "
32
+ "Keep one sketch per test directory."
33
+ )
34
+ return sketch_dir
35
+
36
+
37
+ def find_sketch_yaml(sketch_dir: str | Path) -> Path:
38
+ current = Path(sketch_dir).resolve()
39
+ for candidate_dir in (current, *current.parents):
40
+ candidate = candidate_dir / "sketch.yaml"
41
+ if candidate.is_file():
42
+ return candidate
43
+ raise SketchConfigError(f"sketch.yaml not found from sketch directory: {current}")
44
+
45
+
46
+ def load_sketch_yaml(path: str | Path) -> dict[str, Any]:
47
+ config_path = Path(path)
48
+ with config_path.open("r", encoding="utf-8") as handle:
49
+ data = yaml.safe_load(handle) or {}
50
+
51
+ if not isinstance(data, dict):
52
+ raise SketchConfigError(f"sketch.yaml must contain a mapping: {config_path}")
53
+
54
+ profiles = data.get("profiles", {})
55
+ if profiles is not None and not isinstance(profiles, dict):
56
+ raise SketchConfigError(f"'profiles' must be a mapping in {config_path}")
57
+
58
+ return data
59
+
60
+
61
+ def resolve_profile_name(sketch_data: dict[str, Any], profile: str | None) -> str | None:
62
+ profiles = sketch_data.get("profiles") or {}
63
+ if profile:
64
+ if profiles and profile not in profiles:
65
+ raise SketchConfigError(f"profile '{profile}' not found in sketch.yaml")
66
+ return profile
67
+
68
+ default_profile = sketch_data.get("default_profile")
69
+ if default_profile is not None and not isinstance(default_profile, str):
70
+ raise SketchConfigError("'default_profile' in sketch.yaml must be a string")
71
+
72
+ if default_profile:
73
+ return default_profile
74
+
75
+ if len(profiles) == 1:
76
+ return next(iter(profiles))
77
+
78
+ if len(profiles) > 1:
79
+ raise SketchConfigError(
80
+ "multiple profiles found in sketch.yaml; "
81
+ "specify --profile or set default_profile"
82
+ )
83
+
84
+ return None
85
+
86
+
87
+ def resolve_build_path(sketch_dir: str | Path, profile: str | None, build_path: str | Path | None = None) -> Path:
88
+ if build_path:
89
+ return Path(build_path).resolve()
90
+
91
+ suffix = profile or "default"
92
+ return Path(sketch_dir).resolve() / "build" / suffix
93
+
94
+
95
+ def load_build_config(sketch_dir: str | Path) -> dict[str, Any]:
96
+ config_path = Path(sketch_dir).resolve() / "build_config.toml"
97
+ if not config_path.is_file():
98
+ return {}
99
+
100
+ with config_path.open("rb") as handle:
101
+ data = tomllib.load(handle)
102
+
103
+ if not isinstance(data, dict):
104
+ raise SketchConfigError(f"build_config.toml must contain a mapping: {config_path}")
105
+
106
+ defines = data.get("defines", {})
107
+ if defines is not None and not isinstance(defines, dict):
108
+ raise SketchConfigError(f"'defines' must be a mapping in {config_path}")
109
+
110
+ return data
111
+
112
+
113
+ def _format_define_value(value: str) -> str:
114
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
115
+ return f'"{escaped}"'
116
+
117
+
118
+ def resolve_build_properties(
119
+ sketch_dir: str | Path,
120
+ build_config: dict[str, Any] | None = None,
121
+ ) -> tuple[str, ...]:
122
+ config = build_config if build_config is not None else load_build_config(sketch_dir)
123
+ defines = config.get("defines") or {}
124
+ extra_flags: list[str] = []
125
+
126
+ for env_name, define_name in defines.items():
127
+ if not isinstance(env_name, str) or not isinstance(define_name, str):
128
+ raise SketchConfigError("build_config.toml defines keys and values must be strings")
129
+
130
+ value = os.getenv(env_name, "")
131
+ extra_flags.append(f"-D{define_name}={_format_define_value(value)}")
132
+
133
+ if not extra_flags:
134
+ return ()
135
+
136
+ return (f"build.extra_flags={' '.join(extra_flags)}",)
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class ArduinoCliBuildConfig:
141
+ sketch_dir: Path
142
+ sketch_yaml: Path
143
+ build_path: Path
144
+ profile: str | None = None
145
+ build_properties: tuple[str, ...] = field(default_factory=tuple)
146
+ extra_args: tuple[str, ...] = field(default_factory=tuple)
147
+ clean: bool = False
148
+ cli_path: str = "arduino-cli"
149
+
150
+ @classmethod
151
+ def from_test_path(
152
+ cls,
153
+ test_file_or_dir: str | Path,
154
+ *,
155
+ profile: str | None = None,
156
+ build_path: str | Path | None = None,
157
+ build_properties: tuple[str, ...] = (),
158
+ extra_args: tuple[str, ...] = (),
159
+ clean: bool = False,
160
+ cli_path: str = "arduino-cli",
161
+ ) -> "ArduinoCliBuildConfig":
162
+ sketch_dir = resolve_sketch_dir(test_file_or_dir)
163
+ sketch_yaml = find_sketch_yaml(sketch_dir)
164
+ sketch_data = load_sketch_yaml(sketch_yaml)
165
+ build_config = load_build_config(sketch_dir)
166
+ resolved_profile = resolve_profile_name(sketch_data, profile)
167
+ resolved_build_path = resolve_build_path(sketch_dir, resolved_profile, build_path)
168
+ resolved_build_properties = tuple(build_properties) + resolve_build_properties(sketch_dir, build_config)
169
+ return cls(
170
+ sketch_dir=sketch_dir,
171
+ sketch_yaml=sketch_yaml,
172
+ build_path=resolved_build_path,
173
+ profile=resolved_profile,
174
+ build_properties=resolved_build_properties,
175
+ extra_args=tuple(extra_args),
176
+ clean=clean,
177
+ cli_path=cli_path,
178
+ )
179
+
180
+ def build_command(self) -> list[str]:
181
+ command = [self.cli_path, "compile", "--build-path", str(self.build_path)]
182
+ if self.clean:
183
+ command.append("--clean")
184
+ if self.profile:
185
+ command.extend(["--profile", self.profile])
186
+ for build_property in self.build_properties:
187
+ command.extend(["--build-property", build_property])
188
+ command.extend(self.extra_args)
189
+ command.append(str(self.sketch_dir))
190
+ return command
191
+
192
+ def compile(self, *, check: bool = True) -> subprocess.CompletedProcess[str]:
193
+ return subprocess.run(
194
+ self.build_command(),
195
+ check=check,
196
+ cwd=self.sketch_dir,
197
+ text=True,
198
+ )
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ import subprocess
6
+
7
+ from .app import ArduinoCliBuildConfig
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ArduinoCliUploadConfig:
12
+ sketch_dir: Path
13
+ build_path: Path
14
+ profile: str | None = None
15
+ port: str | None = None
16
+ extra_args: tuple[str, ...] = field(default_factory=tuple)
17
+ cli_path: str = "arduino-cli"
18
+
19
+ @classmethod
20
+ def from_build_config(
21
+ cls,
22
+ build_config: ArduinoCliBuildConfig,
23
+ *,
24
+ port: str | None = None,
25
+ extra_args: tuple[str, ...] = (),
26
+ cli_path: str | None = None,
27
+ ) -> "ArduinoCliUploadConfig":
28
+ return cls(
29
+ sketch_dir=build_config.sketch_dir,
30
+ build_path=build_config.build_path,
31
+ profile=build_config.profile,
32
+ port=port,
33
+ extra_args=tuple(extra_args),
34
+ cli_path=cli_path or build_config.cli_path,
35
+ )
36
+
37
+ def upload_command(self) -> list[str]:
38
+ command = [self.cli_path, "upload", "--build-path", str(self.build_path)]
39
+ if self.profile:
40
+ command.extend(["--profile", self.profile])
41
+ if self.port:
42
+ command.extend(["--port", self.port])
43
+ command.extend(self.extra_args)
44
+ command.append(str(self.sketch_dir))
45
+ return command
46
+
47
+ def upload(self, *, check: bool = True) -> subprocess.CompletedProcess[str]:
48
+ return subprocess.run(
49
+ self.upload_command(),
50
+ check=check,
51
+ cwd=self.sketch_dir,
52
+ text=True,
53
+ )
54
+
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import shlex
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ from .app import ArduinoCliBuildConfig, SketchConfigError, resolve_sketch_dir, resolve_test_path
10
+ from .flasher import ArduinoCliUploadConfig
11
+ from .serial import ensure_default_embedded_services, resolve_port, resolve_upload_port
12
+
13
+
14
+ def _should_build(run_mode: str) -> bool:
15
+ return run_mode in ("all", "build")
16
+
17
+
18
+ def _should_upload(run_mode: str) -> bool:
19
+ return run_mode in ("all", "test")
20
+
21
+
22
+ def pytest_addoption(parser: pytest.Parser) -> None:
23
+ group = parser.getgroup("arduino-cli")
24
+ group.addoption(
25
+ "--run-mode",
26
+ action="store",
27
+ choices=("all", "build", "test"),
28
+ default="all",
29
+ help="Select whether to run build only, upload-and-test with existing artifacts, or build-upload-test.",
30
+ )
31
+ group.addoption(
32
+ "--profile",
33
+ action="store",
34
+ help="Arduino CLI sketch profile name from sketch.yaml.",
35
+ )
36
+
37
+
38
+ def pytest_report_header(config: pytest.Config) -> list[str]:
39
+ return [
40
+ f"arduino-cli run-mode: {config.getoption('run_mode')}",
41
+ f"arduino-cli profile: {config.getoption('profile') or 'default'}",
42
+ ]
43
+
44
+
45
+ def pytest_configure(config: pytest.Config) -> None:
46
+ ensure_default_embedded_services(config)
47
+
48
+
49
+ def _request_path(request: pytest.FixtureRequest) -> Path:
50
+ if hasattr(request, "path"):
51
+ return Path(request.path)
52
+ return Path(str(request.fspath))
53
+
54
+
55
+ def _request_has_sketch(request: pytest.FixtureRequest) -> bool:
56
+ test_path = resolve_test_path(_request_path(request))
57
+ return any(test_path.glob("*.ino"))
58
+
59
+
60
+ def _terminal_reporter(config: pytest.Config) -> Any | None:
61
+ return config.pluginmanager.getplugin("terminalreporter")
62
+
63
+
64
+ def _verbose_level(config: pytest.Config) -> int:
65
+ return int(getattr(config.option, "verbose", 0) or 0)
66
+
67
+
68
+ def _log_command(
69
+ config: pytest.Config,
70
+ *,
71
+ action: str,
72
+ command: list[str],
73
+ details: dict[str, str | None],
74
+ ) -> None:
75
+ verbosity = _verbose_level(config)
76
+ if verbosity < 1:
77
+ return
78
+
79
+ reporter = _terminal_reporter(config)
80
+ if reporter is None:
81
+ return
82
+
83
+ reporter.write_line(f"[arduino-cli] {action}: {shlex.join(command)}")
84
+ if verbosity < 2:
85
+ return
86
+
87
+ for key, value in details.items():
88
+ if value is None:
89
+ continue
90
+ reporter.write_line(f"[arduino-cli] {action} {key}: {value}")
91
+
92
+
93
+ @pytest.fixture
94
+ def app_path(request: pytest.FixtureRequest) -> str:
95
+ if not _request_has_sketch(request):
96
+ return str(resolve_test_path(_request_path(request)))
97
+ return str(resolve_sketch_dir(_request_path(request)))
98
+
99
+
100
+ @pytest.fixture
101
+ def build_dir(request: pytest.FixtureRequest) -> str:
102
+ arduino_cli_app = _build_config_from_request(request, required=False)
103
+ if arduino_cli_app is None:
104
+ return str(resolve_test_path(_request_path(request)) / "build" / "default")
105
+ return str(arduino_cli_app.build_path)
106
+
107
+
108
+ @pytest.fixture
109
+ def skip_autoflash() -> bool:
110
+ # Build/upload are handled explicitly by this plugin instead of pytest-embedded services.
111
+ return True
112
+
113
+
114
+ def _build_config_from_request(
115
+ request: pytest.FixtureRequest,
116
+ *,
117
+ required: bool = True,
118
+ ) -> ArduinoCliBuildConfig | None:
119
+ config = request.config
120
+ should_require = required or _request_has_sketch(request)
121
+ try:
122
+ return ArduinoCliBuildConfig.from_test_path(
123
+ _request_path(request),
124
+ profile=config.getoption("profile"),
125
+ )
126
+ except SketchConfigError:
127
+ if should_require:
128
+ raise
129
+ return None
130
+
131
+
132
+ @pytest.fixture(scope="module")
133
+ def arduino_cli_app(request: pytest.FixtureRequest) -> ArduinoCliBuildConfig:
134
+ return _build_config_from_request(request)
135
+
136
+
137
+ @pytest.fixture(scope="module")
138
+ def arduino_cli_flasher(
139
+ request: pytest.FixtureRequest,
140
+ arduino_cli_app: ArduinoCliBuildConfig,
141
+ ) -> ArduinoCliUploadConfig:
142
+ return ArduinoCliUploadConfig.from_build_config(
143
+ arduino_cli_app,
144
+ port=resolve_upload_port(request.config, profile=arduino_cli_app.profile),
145
+ )
146
+
147
+
148
+ @pytest.fixture(scope="module", autouse=True)
149
+ def arduino_cli_resolved_port(request: pytest.FixtureRequest) -> None:
150
+ arduino_cli_app = _build_config_from_request(request, required=False)
151
+ if arduino_cli_app is None:
152
+ return
153
+
154
+ if getattr(request.config.option, "flash_port", None):
155
+ return
156
+ if getattr(request.config.option, "port", None):
157
+ return
158
+
159
+ resolved_port = resolve_port(request.config, profile=arduino_cli_app.profile)
160
+ if resolved_port:
161
+ request.config.option.port = resolved_port
162
+
163
+
164
+ @pytest.fixture(scope="module", autouse=True)
165
+ def arduino_cli_build(
166
+ request: pytest.FixtureRequest,
167
+ arduino_cli_resolved_port: None,
168
+ ) -> None:
169
+ arduino_cli_app = _build_config_from_request(request, required=False)
170
+ if arduino_cli_app is None:
171
+ return
172
+ if not _should_build(request.config.getoption("run_mode")):
173
+ return
174
+
175
+ _log_command(
176
+ request.config,
177
+ action="compile",
178
+ command=arduino_cli_app.build_command(),
179
+ details={
180
+ "cwd": str(arduino_cli_app.sketch_dir),
181
+ "sketch_dir": str(arduino_cli_app.sketch_dir),
182
+ "build_path": str(arduino_cli_app.build_path),
183
+ "profile": arduino_cli_app.profile,
184
+ },
185
+ )
186
+ arduino_cli_app.compile()
187
+
188
+
189
+ @pytest.fixture(scope="module", autouse=True)
190
+ def arduino_cli_upload(
191
+ request: pytest.FixtureRequest,
192
+ arduino_cli_build: None,
193
+ arduino_cli_resolved_port: None,
194
+ ) -> None:
195
+ run_mode = request.config.getoption("run_mode")
196
+ if not _should_upload(run_mode):
197
+ return
198
+ arduino_cli_app = _build_config_from_request(request, required=False)
199
+ if arduino_cli_app is None:
200
+ return
201
+ if not arduino_cli_app.build_path.is_dir():
202
+ raise FileNotFoundError(
203
+ f"build output directory not found: {arduino_cli_app.build_path}. "
204
+ "Run with --run-mode=all first, or build the sketch before --run-mode=test."
205
+ )
206
+
207
+ arduino_cli_flasher = ArduinoCliUploadConfig.from_build_config(
208
+ arduino_cli_app,
209
+ port=resolve_upload_port(request.config, profile=arduino_cli_app.profile),
210
+ )
211
+
212
+ _log_command(
213
+ request.config,
214
+ action="upload",
215
+ command=arduino_cli_flasher.upload_command(),
216
+ details={
217
+ "cwd": str(arduino_cli_flasher.sketch_dir),
218
+ "sketch_dir": str(arduino_cli_flasher.sketch_dir),
219
+ "build_path": str(arduino_cli_flasher.build_path),
220
+ "profile": arduino_cli_flasher.profile,
221
+ "port": arduino_cli_flasher.port,
222
+ },
223
+ )
224
+ arduino_cli_flasher.upload()
225
+
226
+
227
+ @pytest.fixture(autouse=True)
228
+ def skip_test_execution_in_build_mode(
229
+ request: pytest.FixtureRequest,
230
+ arduino_cli_build: None,
231
+ ) -> None:
232
+ if request.config.getoption("run_mode") == "build":
233
+ pytest.skip("skipped test execution in build-only mode")
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from _pytest.config import Config
4
+ import os
5
+
6
+
7
+ def normalize_profile_name(profile: str) -> str:
8
+ return profile.upper().replace("-", "_")
9
+
10
+
11
+ def ensure_default_embedded_services(config: Config) -> None:
12
+ current = getattr(config.option, "embedded_services", None)
13
+ if not current:
14
+ config.option.embedded_services = "serial"
15
+ return
16
+
17
+ services = [service.strip() for service in current.split(",") if service.strip()]
18
+ if "serial" in services:
19
+ return
20
+
21
+ services.append("serial")
22
+ config.option.embedded_services = ",".join(services)
23
+
24
+
25
+ def resolve_port(config: Config, profile: str | None = None) -> str | None:
26
+ flash_port = getattr(config.option, "flash_port", None)
27
+ if flash_port:
28
+ return flash_port
29
+
30
+ port = getattr(config.option, "port", None)
31
+ if port:
32
+ return port
33
+
34
+ if profile is None:
35
+ profile = getattr(config.option, "profile", None)
36
+
37
+ if profile:
38
+ profile_port = os.getenv(f"TEST_SERIAL_PORT_{normalize_profile_name(profile)}")
39
+ if profile_port:
40
+ return profile_port
41
+
42
+ return os.getenv("TEST_SERIAL_PORT")
43
+
44
+
45
+ def resolve_upload_port(config: Config, profile: str | None = None) -> str | None:
46
+ return resolve_port(config, profile=profile)
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-embedded-arduino-cli
3
+ Version: 0.2.0
4
+ Summary: A pytest plugin to test Arduino projects using pytest-embedded and arduino-cli
5
+ Project-URL: Homepage, https://github.com/tanakamasayuki/pytest-embedded-arduino-cli
6
+ Project-URL: Repository, https://github.com/tanakamasayuki/pytest-embedded-arduino-cli
7
+ Author: TANAKA Masayuki
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: pytest-embedded-serial>=2.0
12
+ Requires-Dist: pytest-embedded>=2.0
13
+ Requires-Dist: pytest>=8
14
+ Requires-Dist: pyyaml>=6.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # pytest-embedded-arduino-cli
18
+
19
+ [日本語版 README](README.ja.md)
20
+
21
+ A pytest plugin to test Arduino projects using `pytest-embedded` and `arduino-cli`.
22
+
23
+ ## Overview
24
+
25
+ `pytest-embedded-arduino-cli` is a small plugin that keeps `pytest-embedded`'s generic DUT / serial / expect flow and replaces Arduino-specific build and upload with `arduino-cli`.
26
+
27
+ This package does not depend on `pytest-embedded-arduino`. It is intended to stay generic enough to work well for Arduino projects beyond ESP32-specific assumptions.
28
+
29
+ ## Design
30
+
31
+ - Build with `arduino-cli compile`
32
+ - Upload with `arduino-cli upload`
33
+ - Use `pytest-embedded` as the runtime foundation
34
+ - Avoid `EspSerial` and ESP-specific flashing services
35
+ - Resolve sketch settings from `sketch.yaml` and `--profile`
36
+ - Treat the test file directory as the sketch directory
37
+
38
+ ## Setup
39
+
40
+ ```bash
41
+ uv init
42
+ uv add pytest-embedded-arduino-cli
43
+ uv sync
44
+ ```
45
+
46
+ Runtime dependencies include:
47
+
48
+ - `pytest`
49
+ - `pytest-embedded`
50
+ - `pytest-embedded-serial`
51
+ - `PyYAML`
52
+
53
+ ## Requirements
54
+
55
+ - `arduino-cli` available in `PATH`
56
+ - Installed Arduino board core(s)
57
+ - A serial port accessible from the host when running hardware tests
58
+
59
+ ## Project Layout
60
+
61
+ The expected layout is one sketch directory per test app.
62
+
63
+ ```text
64
+ tests/
65
+ my_app/
66
+ sketch.yaml
67
+ my_app.ino
68
+ test_my_app.py
69
+ ```
70
+
71
+ When pytest runs a specific `.py` file, this plugin treats that file's directory as the sketch directory. Build settings are resolved from the nearest `sketch.yaml`.
72
+
73
+ ## Usage
74
+
75
+ Build, upload, and run tests:
76
+
77
+ ```bash
78
+ uv run pytest tests/my_app --port /dev/ttyACM0
79
+ ```
80
+
81
+ Select an Arduino CLI profile from `sketch.yaml`:
82
+
83
+ ```bash
84
+ uv run pytest tests/my_app --profile esp32s3 --port /dev/ttyACM0
85
+ ```
86
+
87
+ Build only:
88
+
89
+ ```bash
90
+ uv run pytest tests/my_app --run-mode=build
91
+ ```
92
+
93
+ Upload and test against an already-built image:
94
+
95
+ ```bash
96
+ uv run pytest tests/my_app --run-mode=test --port /dev/ttyACM0
97
+ ```
98
+
99
+ `--run-mode=test` skips compile, reuses the existing build output, uploads it, and then runs the test.
100
+
101
+ Run this package's own tests:
102
+
103
+ ```bash
104
+ uv run pytest
105
+ ```
106
+
107
+ ## Main Options
108
+
109
+ - `--run-mode=all|build|test`
110
+ - `--profile`
111
+
112
+ Use `pytest-embedded` standard options for runtime control, such as:
113
+
114
+ - `--port`
115
+ - `--flash-port`
116
+ - `--baud`
117
+ - `--embedded-services`
118
+
119
+ `pytest-embedded-serial` is installed as a normal dependency so hardware tests can use the serial service without extra package installation.
120
+ If `--embedded-services` is not specified, this plugin enables `serial` by default.
121
+
122
+ For profile-specific serial ports, the plugin resolves ports in this order:
123
+
124
+ 1. `--flash-port`
125
+ 2. `--port`
126
+ 3. `TEST_SERIAL_PORT_<PROFILE>`
127
+ 4. `TEST_SERIAL_PORT`
128
+
129
+ Example:
130
+
131
+ ```bash
132
+ export TEST_SERIAL_PORT_ESP32S3=/dev/ttyUSB1
133
+ uv run pytest tests/my_app --profile esp32s3
134
+ ```
135
+
136
+ If `--profile` is omitted, the plugin uses the resolved profile from `sketch.yaml`, including `default_profile`.
137
+
138
+ For compile-time defines, place a `build_config.toml` in the sketch directory:
139
+
140
+ ```toml
141
+ [defines]
142
+ TEST_WIFI_SSID = "WIFI_SSID"
143
+ TEST_WIFI_PASSWORD = "WIFI_PASSWORD"
144
+ ```
145
+
146
+ Here, the left side is the environment variable name and the right side is the C/C++ define name.
147
+ For example, `TEST_WIFI_SSID` becomes `-DWIFI_SSID="..."` at compile time.
148
+
149
+ Set values before running pytest:
150
+
151
+ ```bash
152
+ export TEST_WIFI_SSID=my-ssid
153
+ export TEST_WIFI_PASSWORD=my-password
154
+ uv run pytest tests/my_app
155
+ ```
156
+
157
+ If an environment variable is missing, the plugin still passes the define with an empty string value.
158
+ This allows the test or sketch code to decide how to handle missing settings.
159
+
160
+ For command visibility, follow pytest's standard verbosity:
161
+
162
+ - `-v` shows the `arduino-cli compile` / `arduino-cli upload` command line
163
+ - `-vv` also shows execution context such as `cwd`, `sketch_dir`, `build_path`, `profile`, and `port`
164
+
165
+ ## Example
166
+
167
+ ```python
168
+ def test_hello(dut):
169
+ dut.expect_exact("hello from arduino")
170
+ ```
171
+
172
+ ```cpp
173
+ void setup() {
174
+ Serial.begin(115200);
175
+ delay(1000);
176
+ Serial.println("hello from arduino");
177
+ }
178
+
179
+ void loop() {}
180
+ ```
181
+
182
+ Additional sample:
183
+
184
+ - `examples/wifi`
185
+ - Connects to Wi-Fi on ESP32/ESP32-S3
186
+ - Includes `uno` as a skip example for a non-Wi-Fi profile
187
+ - Expects `TEST_WIFI_SSID` and `TEST_WIFI_PASSWORD`
188
+ - Verifies that the board prints `WIFI_OK <ip-address>`
189
+
190
+ ## What This Plugin Does Not Try To Be
191
+
192
+ - A drop-in replacement for `pytest-embedded-arduino`
193
+ - An ESP-specific flashing layer
194
+ - A board auto-discovery tool
195
+
196
+ ## Future Extensions
197
+
198
+ - Board-family-specific upload strategies
199
+ - Smarter artifact discovery
200
+ - Serial reset / monitor helpers
201
+ - Multi-device support
202
+ - Optional `fqbn` or sketch path overrides
203
+
204
+ ## Release
205
+
206
+ This repository uses GitHub Actions for releases.
207
+
208
+ Before triggering a release:
209
+
210
+ - Update the `## Unreleased` section in `CHANGELOG.md`
211
+ - Make sure `uv run pytest tests` passes locally if needed
212
+
213
+ Release flow:
214
+
215
+ 1. Open GitHub Actions
216
+ 2. Run the `Release` workflow manually
217
+ 3. Enter the release version such as `0.1.0`
218
+ 4. Choose whether to publish to PyPI
219
+
220
+ The workflow will:
221
+
222
+ - Update versions in `pyproject.toml` and `src/pytest_embedded_arduino_cli/__init__.py`
223
+ - Move `CHANGELOG.md` unreleased entries into `## <version>`
224
+ - Run tests and build the package
225
+ - Commit the release changes and create tag `v<version>`
226
+ - Create a GitHub Release
227
+ - Publish to PyPI when enabled
228
+
229
+ PyPI publishing is configured for Trusted Publishing via GitHub Actions.
@@ -0,0 +1,10 @@
1
+ pytest_embedded_arduino_cli/__init__.py,sha256=9GeHLG02tvvRQBrOVlat-DYqCljAPCZk58KaLpqxZHk,117
2
+ pytest_embedded_arduino_cli/app.py,sha256=BXfXcDxCzGbblSp2YdDeowWKoaJozB2LYqnLJILeFJ4,6676
3
+ pytest_embedded_arduino_cli/flasher.py,sha256=PBOahJploQXowWEL6G5Eyph7rdpVhnWqt_wiMJbF4nw,1591
4
+ pytest_embedded_arduino_cli/plugin.py,sha256=1vfYPDHebpXxstM-wZf7EwlvCQiwckOnKnUqfkJYBxM,7013
5
+ pytest_embedded_arduino_cli/serial.py,sha256=NMw_5PGBNmwormXEM-hwY1jC58INhnDB4lmp62cieHU,1295
6
+ pytest_embedded_arduino_cli-0.2.0.dist-info/METADATA,sha256=3x2ujSLzSCjKiK-wE7SYSuozH566gWbqXoO4A3-wqbw,5955
7
+ pytest_embedded_arduino_cli-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ pytest_embedded_arduino_cli-0.2.0.dist-info/entry_points.txt,sha256=EWR-UiOmXcgVHzTz9aOxfWWXC4hL8XN3wcznjEBi38Q,69
9
+ pytest_embedded_arduino_cli-0.2.0.dist-info/licenses/LICENSE,sha256=VGGOGhKirXWqqgXfW1JW4h5Ekk-5FJ7ybJTeETBpQy4,1072
10
+ pytest_embedded_arduino_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ embedded-arduino-cli = pytest_embedded_arduino_cli.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TANAKA Masayuki
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.