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.
- pytest_embedded_arduino_cli/__init__.py +6 -0
- pytest_embedded_arduino_cli/app.py +198 -0
- pytest_embedded_arduino_cli/flasher.py +54 -0
- pytest_embedded_arduino_cli/plugin.py +233 -0
- pytest_embedded_arduino_cli/serial.py +46 -0
- pytest_embedded_arduino_cli-0.2.0.dist-info/METADATA +229 -0
- pytest_embedded_arduino_cli-0.2.0.dist-info/RECORD +10 -0
- pytest_embedded_arduino_cli-0.2.0.dist-info/WHEEL +4 -0
- pytest_embedded_arduino_cli-0.2.0.dist-info/entry_points.txt +2 -0
- pytest_embedded_arduino_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|