diff --git a/.claude/commands/checks.md b/.claude/commands/checks.md index aa8f380..c77420c 100644 --- a/.claude/commands/checks.md +++ b/.claude/commands/checks.md @@ -4,3 +4,4 @@ Run the following checks: - Refactor if necessary - Approach should be consistent across all concrete implementations 2. Should build cleanly and 'Definition of Done' criteria should all be met +3. In the first top 40 lines of each script in ./examples there's an example CLI command. Ignoring invalid_algo.py, make sure these work. Prefix them with `poetry run` otherwise they won't work. diff --git a/CLAUDE.md b/CLAUDE.md index 43f78d1..8aa5c64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -360,6 +360,8 @@ Optional extras: ctx.time_to_gate_closure() rather than hardcoding offsets. - **time.sleep() pauses the real clock, not the simulated one.** The validation pipeline catches this. Use ctx.wait() for simulated delays. +- **Use `poetry run python` instead of `python` or `python3`** as this loads + the necessary poetry settings and ensures the correct python path. ## Makefile targets diff --git a/Makefile b/Makefile index f8ba20b..24d99fe 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install lint typecheck test ci test-notebooks execute-notebooks +.PHONY: install lint typecheck test ci test-notebooks execute-notebooks clear-notice install: poetry install @@ -20,3 +20,6 @@ test-notebooks: execute-notebooks: poetry run jupyter nbconvert --to notebook --execute --inplace notebooks/*.ipynb + +clear-notice: + rm -f ~/.config/nexa/notice_shown 2>/dev/null diff --git a/README.md b/README.md index ce44098..96e4f03 100644 --- a/README.md +++ b/README.md @@ -489,6 +489,14 @@ nexa compare \ --products NO1_DA --data-dir ./data \ --output reports/comparison.html # .html or .json +# Or select individual classes from a single file with name:path:ClassName +nexa compare \ + "conservative:examples/multi_algo_comparison.py:ConservativeAlgo" \ + "moderate:examples/multi_algo_comparison.py:ModerateAlgo" \ + "aggressive:examples/multi_algo_comparison.py:AggressiveAlgo" \ + --exchange nordpool --start 2026-03-01 --end 2026-03-31 \ + --products NO1_DA --data-dir ./data + nexa validate my_algo.py --exchange nordpool nexa compile my_algo.py --output my_algo.so ``` diff --git a/examples/ml_da_algo.py b/examples/ml_da_algo.py index bb7deee..5144217 100644 --- a/examples/ml_da_algo.py +++ b/examples/ml_da_algo.py @@ -15,7 +15,7 @@ Run:: - python python examples/ml_da_algo.py --data-dir tests/fixtures \\ + python examples/ml_da_algo.py --data-dir tests/fixtures \\ --start 2026-03-01 --end 2026-03-31 --zone NO1 The script will train a model from the DA price history in the Parquet diff --git a/examples/multi_algo_comparison.py b/examples/multi_algo_comparison.py index 8dd8ee1..10489dc 100644 --- a/examples/multi_algo_comparison.py +++ b/examples/multi_algo_comparison.py @@ -8,11 +8,13 @@ python examples/multi_algo_comparison.py -Or via the CLI:: +Or via the CLI (use ``name:path:ClassName`` to select individual classes from +this file):: nexa compare \\ - "conservative:examples/multi_algo_comparison.py" \\ - "aggressive:examples/multi_algo_comparison.py" \\ + "conservative:examples/multi_algo_comparison.py:ConservativeAlgo" \\ + "moderate:examples/multi_algo_comparison.py:ModerateAlgo" \\ + "aggressive:examples/multi_algo_comparison.py:AggressiveAlgo" \\ --exchange nordpool \\ --start 2026-03-01 \\ --end 2026-03-31 \\ diff --git a/src/nexa_backtest/cli/main.py b/src/nexa_backtest/cli/main.py index 7a737fa..8c8071e 100644 --- a/src/nexa_backtest/cli/main.py +++ b/src/nexa_backtest/cli/main.py @@ -18,6 +18,7 @@ import importlib.util import inspect +import os import sys from datetime import datetime from decimal import Decimal @@ -107,6 +108,13 @@ def cli() -> None: "(.onnx → ONNX, .pkl/.joblib → scikit-learn). Repeat for multiple models." ), ) +@click.option( + "--quiet", + "-q", + is_flag=True, + default=False, + help="Suppress non-essential output including the first-run notice.", +) def run_command( algo_file: str, exchange: str, @@ -119,6 +127,7 @@ def run_command( run_validation: bool, strict: bool, model_specs: tuple[str, ...], + quiet: bool, ) -> None: """Run a backtest from ALGO_FILE and print the PnL summary. @@ -194,6 +203,11 @@ def run_command( except Exception as exc: raise click.ClickException(f"Failed to write output to '{output}': {exc}") from exc + if not quiet and not os.environ.get("NEXA_QUIET"): + from nexa_backtest.cli.notice import maybe_show_notice + + maybe_show_notice() + @cli.command("compare") @click.argument("algo_specs", nargs=-1, required=True) @@ -237,6 +251,13 @@ def run_command( "Summary is always printed to stdout." ), ) +@click.option( + "--quiet", + "-q", + is_flag=True, + default=False, + help="Suppress non-essential output including the first-run notice.", +) def compare_command( algo_specs: tuple[str, ...], exchange: str, @@ -246,16 +267,28 @@ def compare_command( data_dir: str, capital: float, output: str | None, + quiet: bool, ) -> None: """Run multiple algos against the same data and compare results. - ALGO_SPECS are algo file paths, optionally prefixed with a display name: + ALGO_SPECS are algo file paths, optionally prefixed with a display name + and/or a class name: \b + # Separate files, one class each nexa compare conservative:algos/conservative.py aggressive:algos/aggressive.py \\ --exchange nordpool --start 2026-03-01 --end 2026-03-31 \\ --products NO1_DA --data-dir ./data + \b + # Single file containing multiple SimpleAlgo subclasses + nexa compare \\ + conservative:examples/multi_algo_comparison.py:ConservativeAlgo \\ + moderate:examples/multi_algo_comparison.py:ModerateAlgo \\ + aggressive:examples/multi_algo_comparison.py:AggressiveAlgo \\ + --exchange nordpool --start 2026-03-01 --end 2026-03-31 \\ + --products NO1_DA --data-dir ./data + If no display name is given (just a path), the filename without extension is used as the display name. Maximum 8 algos. """ @@ -269,20 +302,25 @@ def compare_command( algos: dict[str, Any] = {} for spec in algo_specs: - if ":" in spec: - display_name, _, algo_path = spec.partition(":") + parts = spec.split(":", 2) + if len(parts) == 3: + display_name, algo_path, class_name = parts + elif len(parts) == 2: + display_name, algo_path = parts + class_name = None else: algo_path = spec display_name = Path(algo_path).stem + class_name = None if display_name in algos: raise click.ClickException( f"Duplicate display name '{display_name}'. " - "Use 'name:path' format to provide unique names." + "Use 'name:path' or 'name:path:ClassName' format to provide unique names." ) try: - algo_or_class = _load_algo(algo_path) + algo_or_class = _load_algo(algo_path, class_name=class_name) except (NexaBacktestError, AlgoError) as exc: raise click.ClickException(str(exc)) from exc @@ -330,6 +368,11 @@ def compare_command( f"Failed to write comparison output to '{output}': {exc}" ) from exc + if not quiet and not os.environ.get("NEXA_QUIET"): + from nexa_backtest.cli.notice import maybe_show_notice + + maybe_show_notice() + # ------------------------------------------------------------------ # Algo discovery helpers @@ -353,7 +396,7 @@ def _load_module(path: str) -> ModuleType: return module -def _load_algo(path: str) -> type[SimpleAlgo] | Any: +def _load_algo(path: str, *, class_name: str | None = None) -> type[SimpleAlgo] | Any: """Find a runnable algo in ``path``. Accepts either a unique :class:`~nexa_backtest.algo.SimpleAlgo` subclass @@ -361,8 +404,16 @@ def _load_algo(path: str) -> type[SimpleAlgo] | Any: priority when both are present; an error is raised if multiple candidates of either kind are found. + When ``class_name`` is provided, the discovered subclasses are filtered to + the one with that exact name. This allows a single file containing + multiple :class:`~nexa_backtest.algo.SimpleAlgo` subclasses to be used + with the ``nexa compare`` command via the ``name:path:ClassName`` spec + format. + Args: path: Path to a Python file. + class_name: Optional name of the specific ``SimpleAlgo`` subclass to + load. When omitted the file must contain exactly one subclass. Returns: A :class:`~nexa_backtest.algo.SimpleAlgo` subclass (to be instantiated @@ -370,8 +421,9 @@ def _load_algo(path: str) -> type[SimpleAlgo] | Any: directly to :class:`~nexa_backtest.engines.backtest.BacktestEngine`). Raises: - :class:`click.ClickException`: If no valid algo is found, or if - multiple candidates are found. + :class:`click.ClickException`: If no valid algo is found, if multiple + candidates are found without a ``class_name`` selector, or if the + named class does not exist in the file. """ module = _load_module(path) @@ -395,12 +447,23 @@ def _load_algo(path: str) -> type[SimpleAlgo] | Any: ) return algo_fns[0] + if class_name is not None: + matched = [c for c in subclasses if c.__name__ == class_name] + if not matched: + available = ", ".join(c.__name__ for c in subclasses) if subclasses else "none" + raise click.ClickException( + f"Class '{class_name}' not found in '{path}'. " + f"Available SimpleAlgo subclasses: {available}." + ) + return matched[0] + if subclasses: if len(subclasses) > 1: names = ", ".join(c.__name__ for c in subclasses) raise click.ClickException( f"Multiple SimpleAlgo subclasses found in '{path}': {names}. " - "Move the unused classes to a separate file." + "Use 'name:path:ClassName' format to select a specific class, " + "or move the unused classes to a separate file." ) return subclasses[0] diff --git a/src/nexa_backtest/cli/notice.py b/src/nexa_backtest/cli/notice.py new file mode 100644 index 0000000..fb19145 --- /dev/null +++ b/src/nexa_backtest/cli/notice.py @@ -0,0 +1,52 @@ +"""First-run support notice for the nexa CLI.""" + +import os +import sys +from pathlib import Path + +from nexa_backtest import __version__ + +NOTICE = """\ + +-------------------------------------------------------------------------------- + Thanks for using nexa-backtest. + Support tiers & priority fixes: https://github.com/phasenexa/nexa-backtest/blob/main/SUPPORT.md + Community & questions: https://github.com/phasenexa/nexa-backtest/discussions +--------------------------------------------------------------------------------""" + + +def _config_dir() -> Path: + """Return the nexa config directory. + + On Windows uses ``%APPDATA%\\nexa`` (roaming profile), falling back to + ``~/AppData/Roaming/nexa``. On all other platforms uses + ``$XDG_CONFIG_HOME/nexa``, falling back to ``~/.config/nexa``. + """ + if sys.platform == "win32": + base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") + else: + base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") + return Path(base) / "nexa" + + +def _marker_path() -> Path: + return _config_dir() / "notice_shown" + + +def maybe_show_notice() -> None: + """Show the support notice once, then never again. + + Prints the notice to stderr on first invocation of any nexa CLI command. + After displaying, writes a marker file containing the current version so + the notice is not shown again. On filesystem errors, silently does nothing. + """ + try: + if _marker_path().exists(): + return + + print(NOTICE, file=sys.stderr) + + _config_dir().mkdir(parents=True, exist_ok=True) + _marker_path().write_text(__version__) + except OSError: + pass diff --git a/src/nexa_backtest/cli/validate.py b/src/nexa_backtest/cli/validate.py index ea0684f..e483645 100644 --- a/src/nexa_backtest/cli/validate.py +++ b/src/nexa_backtest/cli/validate.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import os import sys import click @@ -56,6 +57,13 @@ "Repeat for multiple models." ), ) +@click.option( + "--quiet", + "-q", + is_flag=True, + default=False, + help="Suppress non-essential output including the first-run notice.", +) def validate_command( algo_file: str, exchange: str, @@ -63,6 +71,7 @@ def validate_command( skip: str, output_json: bool, model_specs: tuple[str, ...], + quiet: bool, ) -> None: """Validate ALGO_FILE against the target exchange before running. @@ -111,6 +120,11 @@ def validate_command( ) # Exit codes + if not quiet and not os.environ.get("NEXA_QUIET"): + from nexa_backtest.cli.notice import maybe_show_notice + + maybe_show_notice() + if not result.passed: if strict and result.warning_count > 0 and result.error_count == 0: sys.exit(2) diff --git a/src/nexa_backtest/exchanges/nordpool.py b/src/nexa_backtest/exchanges/nordpool.py index d7dfe2e..81a6596 100644 --- a/src/nexa_backtest/exchanges/nordpool.py +++ b/src/nexa_backtest/exchanges/nordpool.py @@ -115,12 +115,12 @@ def gate_closure_offset(self, product_type: str = "IDC") -> timedelta: """Return the gate closure offset for a given product type. Args: - product_type: ``"DA"`` or ``"IDC"``. + product_type: ``"DA"``, ``"IDA"``, or ``"IDC"``. Returns: Time before delivery start that gate closes. """ - if product_type == "DA": + if product_type in ("DA", "IDA"): return NORDPOOL_DA_GATE_CLOSURE return NORDPOOL_IDC_GATE_CLOSURE diff --git a/tests/test_cli/test_main.py b/tests/test_cli/test_main.py index 024f10c..9bb3e9d 100644 --- a/tests/test_cli/test_main.py +++ b/tests/test_cli/test_main.py @@ -84,6 +84,67 @@ class AlgoB(SimpleAlgo): with pytest.raises(click.ClickException, match="Multiple"): find_algo_class(str(algo_file)) + def test_multiple_subclasses_raises_mentions_class_name_hint(self, tmp_path: Path) -> None: + code = """ + from nexa_backtest.algo import SimpleAlgo + class AlgoA(SimpleAlgo): + pass + class AlgoB(SimpleAlgo): + pass + """ + algo_file = _write_algo(tmp_path / "algo.py", code) + import click + + with pytest.raises(click.ClickException, match="name:path:ClassName"): + find_algo_class(str(algo_file)) + + +class TestLoadAlgoClassNameSelection: + """Tests for the optional class_name selector in _load_algo.""" + + def test_select_named_class_from_multi_class_file(self, tmp_path: Path) -> None: + code = """ + from nexa_backtest.algo import SimpleAlgo + class AlgoA(SimpleAlgo): + pass + class AlgoB(SimpleAlgo): + pass + """ + algo_file = _write_algo(tmp_path / "multi.py", code) + from nexa_backtest.cli.main import _load_algo + + result = _load_algo(str(algo_file), class_name="AlgoA") + assert result.__name__ == "AlgoA" + + result_b = _load_algo(str(algo_file), class_name="AlgoB") + assert result_b.__name__ == "AlgoB" + + def test_class_name_not_found_raises(self, tmp_path: Path) -> None: + code = """ + from nexa_backtest.algo import SimpleAlgo + class AlgoA(SimpleAlgo): + pass + """ + algo_file = _write_algo(tmp_path / "single.py", code) + import click + + from nexa_backtest.cli.main import _load_algo + + with pytest.raises(click.ClickException, match="NonExistent"): + _load_algo(str(algo_file), class_name="NonExistent") + + def test_class_name_with_single_class_file(self, tmp_path: Path) -> None: + code = """ + from nexa_backtest.algo import SimpleAlgo + class MyAlgo(SimpleAlgo): + pass + """ + algo_file = _write_algo(tmp_path / "single.py", code) + from nexa_backtest.cli.main import _load_algo + + result = _load_algo(str(algo_file), class_name="MyAlgo") + assert result.__name__ == "MyAlgo" + # --------------------------------------------------------------------------- # Tests: nexa run CLI command diff --git a/tests/test_cli/test_notice.py b/tests/test_cli/test_notice.py new file mode 100644 index 0000000..8ba2ab0 --- /dev/null +++ b/tests/test_cli/test_notice.py @@ -0,0 +1,285 @@ +"""Tests for the first-run support notice (nexa_backtest.cli.notice).""" + +from __future__ import annotations + +import io +from pathlib import Path +from unittest.mock import patch + +import pytest + +from nexa_backtest import __version__ +from nexa_backtest.cli.notice import NOTICE, maybe_show_notice + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_notice( + config_dir: Path, + *, + quiet: bool = False, + nexa_quiet_env: str | None = None, +) -> tuple[str, str]: + """Run the notice logic and return (stdout, stderr) as strings.""" + stdout_buf = io.StringIO() + stderr_buf = io.StringIO() + + env_patch: dict[str, str] = {} + if nexa_quiet_env is not None: + env_patch["NEXA_QUIET"] = nexa_quiet_env + + with ( + patch("nexa_backtest.cli.notice._config_dir", return_value=config_dir), + patch("sys.stdout", stdout_buf), + patch("sys.stderr", stderr_buf), + patch.dict("os.environ", env_patch, clear=False), + ): + if not quiet: + maybe_show_notice() + + return stdout_buf.getvalue(), stderr_buf.getvalue() + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + + +class TestMaybeShowNotice: + def test_shows_on_first_run(self, tmp_path: Path) -> None: + """Marker absent → notice is printed to stderr, marker is created.""" + config_dir = tmp_path / "nexa" + _, stderr = _run_notice(config_dir) + + assert NOTICE in stderr + marker = config_dir / "notice_shown" + assert marker.exists() + assert marker.read_text() == __version__ + + def test_does_not_show_on_second_run(self, tmp_path: Path) -> None: + """Marker present → no output, marker unchanged.""" + config_dir = tmp_path / "nexa" + config_dir.mkdir(parents=True) + marker = config_dir / "notice_shown" + marker.write_text("0.0.1") + + _, stderr = _run_notice(config_dir) + + assert stderr == "" + assert marker.read_text() == "0.0.1" # unchanged + + def test_does_not_pollute_stdout(self, tmp_path: Path) -> None: + """Notice appears on stderr only, stdout remains empty.""" + config_dir = tmp_path / "nexa" + stdout, stderr = _run_notice(config_dir) + + assert NOTICE in stderr + assert NOTICE not in stdout + assert stdout == "" + + def test_creates_missing_config_dir(self, tmp_path: Path) -> None: + """Missing config directory is created together with the marker file.""" + config_dir = tmp_path / "deep" / "nested" / "nexa" + assert not config_dir.exists() + + _run_notice(config_dir) + + assert config_dir.exists() + assert (config_dir / "notice_shown").exists() + + def test_handles_read_only_filesystem(self, tmp_path: Path) -> None: + """OSError on write_text must not propagate.""" + config_dir = tmp_path / "nexa" + + with patch("pathlib.Path.write_text", side_effect=OSError("read-only")): + # Should complete without raising + _run_notice(config_dir) + + def test_respects_quiet_flag(self, tmp_path: Path) -> None: + """When quiet=True, maybe_show_notice is not called at all (guard in caller). + + This test verifies the underlying function still writes normally; the + guard lives in the CLI command, which is tested in the integration tests. + """ + config_dir = tmp_path / "nexa" + # Simulate the caller guard: don't call maybe_show_notice when quiet + stdout_buf = io.StringIO() + stderr_buf = io.StringIO() + + quiet = True + with ( + patch("nexa_backtest.cli.notice._config_dir", return_value=config_dir), + patch("sys.stdout", stdout_buf), + patch("sys.stderr", stderr_buf), + ): + if not quiet: + maybe_show_notice() + + assert stderr_buf.getvalue() == "" + assert not (config_dir / "notice_shown").exists() + + def test_respects_nexa_quiet_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """NEXA_QUIET=1 prevents the notice from being shown or marker written. + + The guard lives in the CLI caller, so we replicate it here. + """ + monkeypatch.setenv("NEXA_QUIET", "1") + import os + + config_dir = tmp_path / "nexa" + stdout_buf = io.StringIO() + stderr_buf = io.StringIO() + + with ( + patch("nexa_backtest.cli.notice._config_dir", return_value=config_dir), + patch("sys.stdout", stdout_buf), + patch("sys.stderr", stderr_buf), + ): + if not os.environ.get("NEXA_QUIET"): + maybe_show_notice() + + assert stderr_buf.getvalue() == "" + assert not (config_dir / "notice_shown").exists() + + +# --------------------------------------------------------------------------- +# Platform-specific config dir tests +# --------------------------------------------------------------------------- + + +class TestConfigDir: + """Unit tests for _config_dir() cross-platform behaviour.""" + + def test_windows_uses_appdata(self, monkeypatch: pytest.MonkeyPatch) -> None: + """On Windows, APPDATA env var is used as the config base.""" + from nexa_backtest.cli.notice import _config_dir + + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.setenv("APPDATA", r"C:\Users\tom\AppData\Roaming") + # XDG_CONFIG_HOME must not interfere on Windows + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + result = _config_dir() + + assert result == Path(r"C:\Users\tom\AppData\Roaming") / "nexa" + + def test_windows_falls_back_to_home_appdata( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """On Windows with APPDATA unset, falls back to ~/AppData/Roaming.""" + from nexa_backtest.cli.notice import _config_dir + + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.delenv("APPDATA", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + # Redirect Path.home() to tmp_path to avoid touching the real home dir + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + + result = _config_dir() + + assert result == tmp_path / "AppData" / "Roaming" / "nexa" + + +# --------------------------------------------------------------------------- +# CLI integration tests +# --------------------------------------------------------------------------- + + +class TestCliQuietFlag: + """Verify --quiet suppresses the notice and does not write the marker.""" + + def _marker_exists(self, tmp_path: Path) -> bool: + return (tmp_path / "nexa" / "notice_shown").exists() + + def test_run_command_quiet_suppresses_notice( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from click.testing import CliRunner + + from nexa_backtest.cli.main import run_command + + config_dir = tmp_path / "nexa" + monkeypatch.setattr("nexa_backtest.cli.notice._config_dir", lambda: config_dir) + + runner = CliRunner() + # Invoke with --quiet; even if the engine would fail, notice must not appear + result = runner.invoke( + run_command, + [ + "non_existent_algo.py", + "--exchange", + "nordpool", + "--start", + "2026-01-01", + "--end", + "2026-01-31", + "--products", + "NO1_DA", + "--data-dir", + str(tmp_path), + "--quiet", + ], + ) + assert NOTICE not in (result.output or "") + assert not self._marker_exists(tmp_path) + + def test_validate_command_quiet_suppresses_notice( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from click.testing import CliRunner + + from nexa_backtest.cli.validate import validate_command + + config_dir = tmp_path / "nexa" + monkeypatch.setattr("nexa_backtest.cli.notice._config_dir", lambda: config_dir) + + # Write a minimal valid-looking algo file + algo_file = tmp_path / "dummy_algo.py" + algo_file.write_text("# placeholder\n") + + runner = CliRunner() + result = runner.invoke( + validate_command, + [ + str(algo_file), + "--exchange", + "nordpool", + "--quiet", + ], + ) + assert NOTICE not in (result.output or "") + assert not self._marker_exists(tmp_path) + + def test_compare_command_quiet_suppresses_notice( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from click.testing import CliRunner + + from nexa_backtest.cli.main import compare_command + + config_dir = tmp_path / "nexa" + monkeypatch.setattr("nexa_backtest.cli.notice._config_dir", lambda: config_dir) + + runner = CliRunner() + result = runner.invoke( + compare_command, + [ + "a.py", + "b.py", + "--exchange", + "nordpool", + "--start", + "2026-01-01", + "--end", + "2026-01-31", + "--products", + "NO1_DA", + "--data-dir", + str(tmp_path), + "--quiet", + ], + ) + assert NOTICE not in (result.output or "") + assert not self._marker_exists(tmp_path)