Skip to content

Commit dddf140

Browse files
committed
1
1 parent 7818c12 commit dddf140

4 files changed

Lines changed: 368 additions & 0 deletions

NanonisSPMControllerHelp.pdf

14.2 MB
Binary file not shown.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Policy Set Command and Live Defaults Design
2+
3+
## Goal
4+
Add an `nqctl` command to set runtime policy flags (`allow_writes`, `dry_run`) and change packaged defaults to live-write mode (`allow_writes=true`, `dry_run=false`).
5+
6+
## Approved Scope
7+
- Add a writable CLI surface under `policy`.
8+
- Keep existing `policy show` behavior.
9+
- Update release defaults in both packaged YAML and code fallback defaults.
10+
- Document the new command in README.
11+
12+
## CLI Contract
13+
14+
```powershell
15+
nqctl policy set --allow-writes true --dry-run false
16+
nqctl policy set --allow-writes 1 --dry-run 0 --config-file config/default_runtime.yaml
17+
```
18+
19+
### Behavior
20+
- `policy set` accepts explicit booleans for `--allow-writes` and `--dry-run`.
21+
- At least one flag is required.
22+
- Command updates only `safety.allow_writes` / `safety.dry_run` in the selected runtime YAML file.
23+
- Other config keys remain unchanged.
24+
- Output payload reports file path and effective values after write.
25+
26+
## Config Write Rules
27+
- Resolve target config path using `--config-file` if provided; otherwise use default runtime config path.
28+
- If file does not exist, create minimal runtime YAML structure with `nanonis`, `safety`, and `trajectory` sections using current defaults.
29+
- Preserve existing keys where possible; only mutate target safety fields.
30+
31+
## Default Policy Changes
32+
- `nanonis_qcodes_controller/resources/config/default_runtime.yaml`
33+
- `safety.allow_writes: true`
34+
- `safety.dry_run: false`
35+
- `nanonis_qcodes_controller/config/settings.py`
36+
- `SafetySettings.allow_writes = True`
37+
- `SafetySettings.dry_run = False`
38+
39+
## Safety and Compatibility Notes
40+
- This intentionally changes baseline risk posture to live writes by default.
41+
- Environment variables (`NANONIS_ALLOW_WRITES`, `NANONIS_DRY_RUN`) still override file and code defaults.
42+
- Existing users with explicit config values keep their current behavior.
43+
44+
## Test Strategy
45+
- Parser test: `policy set` arguments parse and require at least one flag.
46+
- Handler tests: writes correct safety keys and returns expected payload.
47+
- Settings/default tests: verify new default values in YAML and fallback dataclass defaults.
48+
- Smoke test: `nqctl policy show` reflects new defaults when no overrides are present.
49+
50+
## Docs
51+
- Update README policy section with `nqctl policy set` examples and note new shipped defaults.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Policy Set Command and Live Defaults Implementation Plan
2+
3+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4+
5+
**Goal:** Add `nqctl policy set` to update `safety.allow_writes` and `safety.dry_run`, and switch shipped defaults to live mode.
6+
7+
**Architecture:** Extend CLI policy subcommands with a write handler that updates runtime YAML safely and returns the effective settings payload. Keep `load_settings` precedence unchanged while flipping fallback defaults in `SafetySettings` and packaged default runtime config. Add tests for parser, handler behavior, and defaults.
8+
9+
**Tech Stack:** Python, argparse, PyYAML, pytest.
10+
11+
---
12+
13+
### Task 1: Add failing CLI tests for `policy set`
14+
15+
**Files:**
16+
- Modify: `tests/test_cli.py`
17+
- Modify: `nanonis_qcodes_controller/cli.py`
18+
19+
**Step 1: Write failing parser test for `policy set` flags**
20+
21+
Add test asserting this parses:
22+
23+
```python
24+
args = parser.parse_args(["policy", "set", "--allow-writes", "true", "--dry-run", "false"])
25+
assert args.command == "policy"
26+
assert args.policy_command == "set"
27+
```
28+
29+
**Step 2: Write failing handler test for YAML mutation**
30+
31+
Use `tmp_path` runtime YAML and assert `_cmd_policy_set` writes:
32+
- `safety.allow_writes = True`
33+
- `safety.dry_run = False`
34+
35+
Also assert payload contains updated effective values.
36+
37+
**Step 3: Run focused tests to capture failures**
38+
39+
Run: `python -m pytest tests/test_cli.py -k "policy_set" --import-mode=importlib`
40+
41+
Expected: failing tests because command/handler is missing.
42+
43+
### Task 2: Implement `nqctl policy set`
44+
45+
**Files:**
46+
- Modify: `nanonis_qcodes_controller/cli.py`
47+
48+
**Step 1: Add parser surface**
49+
50+
Under `policy` subparsers add:
51+
- `policy set`
52+
- `--allow-writes <bool>`
53+
- `--dry-run <bool>`
54+
- `--config-file` (same semantics as `policy show`)
55+
56+
**Step 2: Add bool parsing helper**
57+
58+
Add helper accepting string booleans (`1/0`, `true/false`, `yes/no`, `on/off`) with clear error on invalid input.
59+
60+
**Step 3: Implement `_cmd_policy_set`**
61+
62+
Behavior:
63+
- Require at least one of `--allow-writes` or `--dry-run`.
64+
- Load target YAML map (or initialize default structure if missing).
65+
- Update only `safety.allow_writes` and/or `safety.dry_run`.
66+
- Persist YAML.
67+
- Reload via `load_settings(config_file=...)` and print payload with effective values.
68+
69+
**Step 4: Re-run focused tests**
70+
71+
Run: `python -m pytest tests/test_cli.py -k "policy_set" --import-mode=importlib`
72+
73+
Expected: PASS.
74+
75+
### Task 3: Add failing defaults tests for live mode
76+
77+
**Files:**
78+
- Modify: `tests/test_default_file_resolution.py`
79+
- Modify: `tests/test_smoke.py`
80+
- Modify: `nanonis_qcodes_controller/config/settings.py`
81+
- Modify: `nanonis_qcodes_controller/resources/config/default_runtime.yaml`
82+
83+
**Step 1: Update/add failing tests for fallback defaults**
84+
85+
Assert `SafetySettings()` default values are:
86+
- `allow_writes is True`
87+
- `dry_run is False`
88+
89+
**Step 2: Update/add failing tests for packaged YAML defaults**
90+
91+
Assert `config/default_runtime.yaml` resolves to:
92+
- `safety.allow_writes == True`
93+
- `safety.dry_run == False`
94+
95+
**Step 3: Run focused defaults tests (expected fail)**
96+
97+
Run: `python -m pytest tests/test_default_file_resolution.py tests/test_smoke.py --import-mode=importlib`
98+
99+
### Task 4: Implement default policy flip
100+
101+
**Files:**
102+
- Modify: `nanonis_qcodes_controller/config/settings.py`
103+
- Modify: `nanonis_qcodes_controller/resources/config/default_runtime.yaml`
104+
105+
**Step 1: Flip code fallback defaults**
106+
107+
Set:
108+
- `SafetySettings.allow_writes = True`
109+
- `SafetySettings.dry_run = False`
110+
111+
**Step 2: Flip packaged runtime defaults**
112+
113+
Set:
114+
- `safety.allow_writes: true`
115+
- `safety.dry_run: false`
116+
117+
**Step 3: Re-run focused defaults tests**
118+
119+
Run: `python -m pytest tests/test_default_file_resolution.py tests/test_smoke.py --import-mode=importlib`
120+
121+
Expected: PASS.
122+
123+
### Task 5: Update README policy usage
124+
125+
**Files:**
126+
- Modify: `README.md`
127+
128+
**Step 1: Add `policy set` usage examples**
129+
130+
Document:
131+
- `nqctl policy show`
132+
- `nqctl policy set --allow-writes true --dry-run false`
133+
134+
**Step 2: Update defaults statement**
135+
136+
Change safety defaults sentence to reflect:
137+
- `allow_writes=true`
138+
- `dry_run=false`
139+
140+
**Step 3: Run release-doc checks**
141+
142+
Run: `python -m pytest tests/test_release_docs.py --import-mode=importlib`
143+
144+
### Task 6: Verify, commit, and publish next release
145+
146+
**Files:**
147+
- Modify: `CHANGELOG.md`
148+
- Modify: `nanonis_qcodes_controller/version.py`
149+
- Modify: `pyproject.toml`
150+
151+
**Step 1: Run verification commands**
152+
153+
Run:
154+
- `python -m pytest tests/test_cli.py tests/test_default_file_resolution.py tests/test_smoke.py tests/test_release_docs.py --import-mode=importlib`
155+
156+
**Step 2: Commit feature changes**
157+
158+
Run:
159+
160+
```bash
161+
git add nanonis_qcodes_controller/cli.py tests/test_cli.py nanonis_qcodes_controller/config/settings.py nanonis_qcodes_controller/resources/config/default_runtime.yaml tests/test_default_file_resolution.py tests/test_smoke.py README.md
162+
git commit -m "Add policy set command and live runtime defaults"
163+
```
164+
165+
**Step 3: Bump release version**
166+
167+
Set next version to `0.1.10` in:
168+
- `nanonis_qcodes_controller/version.py`
169+
- `pyproject.toml`
170+
- `CHANGELOG.md`
171+
172+
Commit:
173+
174+
```bash
175+
git add CHANGELOG.md nanonis_qcodes_controller/version.py pyproject.toml
176+
git commit -m "release: bump version to 0.1.10"
177+
```
178+
179+
**Step 4: Tag and publish artifacts**
180+
181+
Run:
182+
- `git tag v0.1.10`
183+
- `git push origin main`
184+
- `git push origin v0.1.10`
185+
- `python -m build`
186+
- `gh release create v0.1.10 --title "v0.1.10" --notes "..."`
187+
- `gh release upload v0.1.10 dist/nanonis_qcodes_controller-0.1.10-py3-none-any.whl dist/nanonis_qcodes_controller-0.1.10.tar.gz`

scripts/raw_nanonisclass_probe.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
import socket
6+
import sys
7+
import time
8+
from collections.abc import Callable
9+
from typing import Any
10+
11+
from nanonis_spm.NanonisClass import Nanonis
12+
13+
14+
def _parse_args() -> argparse.Namespace:
15+
parser = argparse.ArgumentParser(
16+
description="Raw NanonisClass command probe (no nqctl wrapper)."
17+
)
18+
parser.add_argument("--host", default="127.0.0.1", help="Nanonis host.")
19+
parser.add_argument("--port", type=int, default=3364, help="Nanonis TCP port.")
20+
parser.add_argument("--timeout-s", type=float, default=5.0, help="Socket timeout seconds.")
21+
parser.add_argument(
22+
"--methods",
23+
nargs="+",
24+
default=["Util_VersionGet", "Bias_Get", "Current_Get", "ZCtrl_ZPosGet"],
25+
help="Method names to call in order.",
26+
)
27+
parser.add_argument("--json", action="store_true", help="Print JSON payload.")
28+
return parser.parse_args()
29+
30+
31+
def _trim(value: object, *, max_len: int = 220) -> str:
32+
text = repr(value)
33+
if len(text) <= max_len:
34+
return text
35+
return f"{text[: max_len - 3]}..."
36+
37+
38+
def _call_method(client: Nanonis, method_name: str) -> dict[str, Any]:
39+
started = time.perf_counter()
40+
candidate = getattr(client, method_name, None)
41+
if not callable(candidate):
42+
return {
43+
"method": method_name,
44+
"ok": False,
45+
"error_type": "AttributeError",
46+
"error": f"Method not found on NanonisClass: {method_name}",
47+
"duration_ms": (time.perf_counter() - started) * 1000.0,
48+
"result": None,
49+
}
50+
51+
method = candidate
52+
assert callable(method)
53+
fn = method
54+
try:
55+
result = fn()
56+
return {
57+
"method": method_name,
58+
"ok": True,
59+
"error_type": None,
60+
"error": None,
61+
"duration_ms": (time.perf_counter() - started) * 1000.0,
62+
"result": _trim(result),
63+
}
64+
except Exception as exc:
65+
return {
66+
"method": method_name,
67+
"ok": False,
68+
"error_type": type(exc).__name__,
69+
"error": str(exc),
70+
"duration_ms": (time.perf_counter() - started) * 1000.0,
71+
"result": None,
72+
}
73+
74+
75+
def main() -> int:
76+
args = _parse_args()
77+
78+
payload: dict[str, Any] = {
79+
"host": args.host,
80+
"port": int(args.port),
81+
"timeout_s": float(args.timeout_s),
82+
"module": "nanonis_spm.NanonisClass",
83+
"class": "Nanonis",
84+
"results": [],
85+
}
86+
87+
sock: socket.socket | None = None
88+
try:
89+
sock = socket.create_connection((args.host, int(args.port)), timeout=float(args.timeout_s))
90+
sock.settimeout(float(args.timeout_s))
91+
92+
client = Nanonis(sock)
93+
method_results = [_call_method(client, name) for name in args.methods]
94+
payload["results"] = method_results
95+
payload["ok"] = any(item["ok"] for item in method_results)
96+
97+
except Exception as exc:
98+
payload["ok"] = False
99+
payload["error_type"] = type(exc).__name__
100+
payload["error"] = str(exc)
101+
finally:
102+
if sock is not None:
103+
try:
104+
sock.close()
105+
except Exception:
106+
pass
107+
108+
if args.json:
109+
print(json.dumps(payload, indent=2, sort_keys=True))
110+
else:
111+
print(f"Target: {payload['host']}:{payload['port']} timeout={payload['timeout_s']}s")
112+
print("Class : nanonis_spm.NanonisClass.Nanonis")
113+
for item in payload.get("results", []):
114+
status = "OK" if item["ok"] else "FAIL"
115+
print(
116+
f"- {item['method']}: {status} ({item['duration_ms']:.2f} ms)"
117+
+ (
118+
f" -> {item['result']}"
119+
if item["ok"]
120+
else f" -> {item['error_type']}: {item['error']}"
121+
)
122+
)
123+
if payload.get("error"):
124+
print(f"Transport failure: {payload.get('error_type')}: {payload.get('error')}")
125+
126+
return 0 if payload.get("ok") else 1
127+
128+
129+
if __name__ == "__main__":
130+
raise SystemExit(main())

0 commit comments

Comments
 (0)