Skip to content

Commit 9b6b665

Browse files
committed
Add policy set command and live runtime defaults
1 parent 89a07f4 commit 9b6b665

File tree

6 files changed

+173
-8
lines changed

6 files changed

+173
-8
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Simulator-first Python bridge between Nanonis SPM controller interfaces and QCod
99
- Strict write semantics:
1010
- `set` is always a guarded single-step write.
1111
- `ramp` is always an explicit multi-step trajectory.
12-
- Safety-first defaults (`allow_writes=false`, `dry_run=true`).
12+
- Default runtime policy (`allow_writes=true`, `dry_run=false`).
1313

1414
## v1 API support contract
1515

@@ -298,10 +298,11 @@ nqctl observables list
298298
nqctl actions list
299299
```
300300

301-
Inspect active runtime policy:
301+
Inspect and update runtime policy:
302302

303303
```powershell
304304
nqctl policy show
305+
nqctl policy set --allow-writes true --dry-run false
305306
```
306307

307308
### Execute operations

nanonis_qcodes_controller/cli.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import inspect
77
import json
88
import math
9+
import os
910
import re
1011
import sqlite3
1112
import sys
@@ -17,6 +18,8 @@
1718
from pathlib import Path
1819
from typing import Any
1920

21+
import yaml
22+
2023
from nanonis_qcodes_controller.client import create_client, probe_host_ports, report_to_dict
2124
from nanonis_qcodes_controller.client.errors import (
2225
NanonisCommandUnavailableError,
@@ -57,6 +60,9 @@
5760
re.IGNORECASE,
5861
)
5962

63+
_TRUE_BOOL_TOKENS = frozenset({"1", "true", "yes", "on"})
64+
_FALSE_BOOL_TOKENS = frozenset({"0", "false", "no", "off"})
65+
6066

6167
@dataclass(frozen=True)
6268
class ActionDescriptor:
@@ -365,6 +371,23 @@ def _build_parser() -> argparse.ArgumentParser:
365371
parser_policy_show.add_argument("--config-file")
366372
parser_policy_show.set_defaults(handler=_cmd_policy_show)
367373

374+
parser_policy_set = policy_subparsers.add_parser(
375+
"set", help="Set runtime policy write/dry-run flags."
376+
)
377+
_add_json_arg(parser_policy_set)
378+
parser_policy_set.add_argument(
379+
"--allow-writes",
380+
type=_parse_cli_bool_arg,
381+
help="Override safety.allow_writes (1/0, true/false, yes/no, on/off).",
382+
)
383+
parser_policy_set.add_argument(
384+
"--dry-run",
385+
type=_parse_cli_bool_arg,
386+
help="Override safety.dry_run (1/0, true/false, yes/no, on/off).",
387+
)
388+
parser_policy_set.add_argument("--config-file")
389+
parser_policy_set.set_defaults(handler=_cmd_policy_set)
390+
368391
parser_trajectory = subparsers.add_parser("trajectory", help="Trajectory log utilities.")
369392
trajectory_subparsers = parser_trajectory.add_subparsers(
370393
dest="trajectory_command", required=True
@@ -826,6 +849,44 @@ def _cmd_policy_show(args: argparse.Namespace) -> int:
826849
return EXIT_OK
827850

828851

852+
def _cmd_policy_set(args: argparse.Namespace) -> int:
853+
allow_writes = args.allow_writes
854+
dry_run = args.dry_run
855+
if allow_writes is None and dry_run is None:
856+
raise ValueError("Provide at least one policy flag: --allow-writes and/or --dry-run.")
857+
858+
config_path = _resolve_policy_config_path(args.config_file)
859+
config_values = _load_runtime_config_yaml(config_path)
860+
safety_section = config_values.get("safety")
861+
if safety_section is None:
862+
updated_safety: dict[str, Any] = {}
863+
elif isinstance(safety_section, Mapping):
864+
updated_safety = dict(safety_section)
865+
else:
866+
raise ValueError("Runtime config section 'safety' must be a mapping.")
867+
868+
if allow_writes is not None:
869+
updated_safety["allow_writes"] = bool(allow_writes)
870+
if dry_run is not None:
871+
updated_safety["dry_run"] = bool(dry_run)
872+
config_values["safety"] = updated_safety
873+
874+
config_path.parent.mkdir(parents=True, exist_ok=True)
875+
with config_path.open("w", encoding="utf-8") as handle:
876+
yaml.safe_dump(config_values, handle, sort_keys=False)
877+
878+
settings = load_settings(config_file=config_path)
879+
payload = {
880+
"allow_writes": settings.safety.allow_writes,
881+
"dry_run": settings.safety.dry_run,
882+
"default_ramp_interval_s": settings.safety.default_ramp_interval_s,
883+
"config_file": str(config_path),
884+
"timestamp_utc": _now_utc_iso(),
885+
}
886+
_print_payload(payload, as_json=args.json)
887+
return EXIT_OK
888+
889+
829890
def _cmd_parameters_discover(args: argparse.Namespace) -> int:
830891
commands = list(_discover_nanonis_spm_commands(args.match))
831892
if args.limit > 0:
@@ -1428,6 +1489,42 @@ def _parse_float_arg(*, name: str, raw_value: str) -> float:
14281489
raise ValueError(f"{name} must be numeric.") from exc
14291490

14301491

1492+
def _parse_cli_bool_arg(raw_value: object) -> bool:
1493+
if isinstance(raw_value, bool):
1494+
return raw_value
1495+
1496+
normalized = str(raw_value).strip().lower()
1497+
if normalized in _TRUE_BOOL_TOKENS:
1498+
return True
1499+
if normalized in _FALSE_BOOL_TOKENS:
1500+
return False
1501+
1502+
raise argparse.ArgumentTypeError("Expected boolean value: 1/0, true/false, yes/no, on/off.")
1503+
1504+
1505+
def _resolve_policy_config_path(config_file: str | None) -> Path:
1506+
if config_file is not None:
1507+
return Path(config_file).expanduser()
1508+
env_override = os.environ.get("NANONIS_CONFIG_FILE")
1509+
if env_override:
1510+
return Path(env_override).expanduser()
1511+
return Path("config/default_runtime.yaml")
1512+
1513+
1514+
def _load_runtime_config_yaml(config_path: Path) -> dict[str, Any]:
1515+
if not config_path.exists():
1516+
return {}
1517+
1518+
with config_path.open("r", encoding="utf-8") as handle:
1519+
loaded = yaml.safe_load(handle)
1520+
1521+
if loaded is None:
1522+
return {}
1523+
if not isinstance(loaded, Mapping):
1524+
raise ValueError("Runtime config file must contain a top-level mapping.")
1525+
return dict(loaded)
1526+
1527+
14311528
def _parse_positive_float_arg(*, name: str, raw_value: str) -> float:
14321529
value = _parse_float_arg(name=name, raw_value=raw_value)
14331530
if value <= 0:

nanonis_qcodes_controller/config/settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ class NanonisConnectionSettings:
2828

2929
@dataclass(frozen=True)
3030
class SafetySettings:
31-
allow_writes: bool = False
32-
dry_run: bool = True
31+
allow_writes: bool = True
32+
dry_run: bool = False
3333
default_ramp_interval_s: float = DEFAULT_RAMP_INTERVAL_S
3434

3535

nanonis_qcodes_controller/resources/config/default_runtime.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ nanonis:
1111
backend: "adapter"
1212

1313
safety:
14-
allow_writes: false
15-
dry_run: true
14+
allow_writes: true
15+
dry_run: false
1616
default_ramp_interval_s: 0.05
1717

1818
trajectory:

tests/test_cli.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ def test_showall_parser_available() -> None:
5959
assert args.command == "showall"
6060

6161

62+
def test_policy_set_parser_accepts_boolean_overrides() -> None:
63+
parser = cli._build_parser()
64+
args = parser.parse_args(["policy", "set", "--allow-writes", "true", "--dry-run", "false"])
65+
assert args.command == "policy"
66+
assert args.policy_command == "set"
67+
assert args.allow_writes is True
68+
assert args.dry_run is False
69+
70+
71+
def test_policy_set_parser_rejects_invalid_boolean_token() -> None:
72+
parser = cli._build_parser()
73+
with pytest.raises(SystemExit):
74+
_ = parser.parse_args(["policy", "set", "--allow-writes", "maybe"])
75+
76+
6277
def test_act_parser_supports_repeatable_arg_flags() -> None:
6378
parser = cli._build_parser()
6479
args = parser.parse_args(
@@ -210,6 +225,52 @@ def fake_instrument_context(*_args, **_kwargs):
210225
assert captured_step_values == pytest.approx([1e-11, 1e-11])
211226

212227

228+
def test_cmd_policy_set_updates_config_and_emits_effective_policy(monkeypatch, tmp_path) -> None:
229+
captured_payloads: list[dict[str, object]] = []
230+
231+
def fake_print_payload(payload, *, as_json: bool) -> None:
232+
del as_json
233+
captured_payloads.append(dict(payload))
234+
235+
monkeypatch.setattr(cli, "_print_payload", fake_print_payload)
236+
237+
config_file = tmp_path / "runtime.yaml"
238+
config_file.write_text(
239+
"\n".join(
240+
(
241+
"nanonis:",
242+
' host: "127.0.0.1"',
243+
" ports: [3364]",
244+
" timeout_s: 2.0",
245+
" retry_count: 1",
246+
' backend: "adapter"',
247+
"safety:",
248+
" allow_writes: false",
249+
" dry_run: true",
250+
" default_ramp_interval_s: 0.05",
251+
"trajectory:",
252+
" enabled: false",
253+
' directory: "artifacts/trajectory"',
254+
" queue_size: 2048",
255+
" max_events_per_file: 5000",
256+
)
257+
)
258+
+ "\n",
259+
encoding="utf-8",
260+
)
261+
262+
args = argparse.Namespace(
263+
allow_writes=True, dry_run=False, config_file=str(config_file), json=True
264+
)
265+
exit_code = getattr(cli, "_cmd_policy_set")(args)
266+
267+
assert exit_code == cli.EXIT_OK
268+
assert captured_payloads
269+
payload = captured_payloads[-1]
270+
assert payload["allow_writes"] is True
271+
assert payload["dry_run"] is False
272+
273+
213274
def test_parse_action_args_rejects_invalid_entries() -> None:
214275
with pytest.raises(ValueError, match="key=value"):
215276
_ = cli._parse_action_args(raw_args=("Scan_action",))

tests/test_default_file_resolution.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,14 @@ def _tracking_resolver(name: str):
7070
settings = load_settings()
7171

7272
assert calls == ["default_runtime.yaml"]
73-
assert settings.safety.allow_writes is False
74-
assert settings.safety.dry_run is True
73+
assert settings.safety.allow_writes is True
74+
assert settings.safety.dry_run is False
75+
76+
77+
def test_safety_settings_defaults_are_live_write_mode() -> None:
78+
defaults = settings_module.SafetySettings()
79+
assert defaults.allow_writes is True
80+
assert defaults.dry_run is False
7581

7682

7783
def test_load_parameter_specs_falls_back_when_default_path_missing(tmp_path, monkeypatch) -> None:

0 commit comments

Comments
 (0)