closecode-ai 0.4.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (43) hide show
  1. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/PKG-INFO +35 -3
  2. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/README.md +33 -3
  3. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/PKG-INFO +35 -3
  4. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/SOURCES.txt +15 -1
  5. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/requires.txt +3 -0
  6. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/top_level.txt +5 -0
  7. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/pyproject.toml +19 -1
  8. closecode_ai-0.5.0/tests/test_config.py +175 -0
  9. closecode_ai-0.5.0/tests/test_guardrails.py +357 -0
  10. closecode_ai-0.5.0/tests/test_harness.py +678 -0
  11. closecode_ai-0.5.0/tests/test_llm.py +332 -0
  12. closecode_ai-0.5.0/tests/test_modes.py +262 -0
  13. closecode_ai-0.5.0/tests/test_search.py +274 -0
  14. closecode_ai-0.5.0/tests/test_session.py +395 -0
  15. closecode_ai-0.5.0/tests/test_todos.py +271 -0
  16. closecode_ai-0.5.0/tests/test_token_tracker.py +241 -0
  17. closecode_ai-0.5.0/tui.py +372 -0
  18. closecode_ai-0.5.0/tui_messages.py +167 -0
  19. closecode_ai-0.5.0/tui_theme.py +170 -0
  20. closecode_ai-0.5.0/tui_widgets.py +421 -0
  21. closecode_ai-0.5.0/ui.py +272 -0
  22. closecode_ai-0.5.0/ui_prompts.py +180 -0
  23. closecode_ai-0.5.0/ui_theme.py +79 -0
  24. closecode_ai-0.4.0/tui.py +0 -1047
  25. closecode_ai-0.4.0/ui.py +0 -463
  26. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/agent.py +0 -0
  27. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/dependency_links.txt +0 -0
  28. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/entry_points.txt +0 -0
  29. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/config.py +0 -0
  30. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/debug_response.py +0 -0
  31. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/guardrails.py +0 -0
  32. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/harness.py +0 -0
  33. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/llm.py +0 -0
  34. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/main.py +0 -0
  35. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/mcp_tools.py +0 -0
  36. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/modes.py +0 -0
  37. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/render.py +0 -0
  38. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/search.py +0 -0
  39. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/session.py +0 -0
  40. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/setup.cfg +0 -0
  41. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/todos.py +0 -0
  42. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/token_tracker.py +0 -0
  43. {closecode_ai-0.4.0 → closecode_ai-0.5.0}/tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: closecode-ai
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: CloseCode — an agentic terminal coding assistant (LangGraph + OpenRouter + MCP)
5
5
  Author: Om Gite
6
6
  License-Expression: MIT
@@ -20,6 +20,8 @@ Requires-Dist: langchain-openrouter
20
20
  Requires-Dist: rich
21
21
  Requires-Dist: pyfiglet
22
22
  Requires-Dist: textual
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
23
25
 
24
26
  <div align="center">
25
27
 
@@ -99,6 +101,22 @@ closecode
99
101
  `pipx` is recommended over `pip` for the packaged version since it installs CLI tools into
100
102
  an isolated environment and puts them straight on your `PATH`.
101
103
 
104
+ ## Tests
105
+
106
+ ```bash
107
+ pip install -e ".[dev]"
108
+ pytest
109
+ ```
110
+
111
+ 429 tests covering guardrails, sandbox path resolution, undo/checkpoints, background
112
+ processes, todo-store invariants, plan-mode tool filtering, session persistence, config
113
+ permissions, and the search tools. The suite is fully isolated: `config.py` and
114
+ `session.py` are redirected to temp dirs, no test touches the network, and nothing is
115
+ written to your real `~/.closecode`.
116
+
117
+ Some tests are marked `xfail` for **known bugs** rather than fixed behaviour — they
118
+ document a gap and will flip to passing when it's fixed. Run `pytest -rx` to list them.
119
+
102
120
  ## Quick start
103
121
 
104
122
  1. Get a free API key at [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys)
@@ -138,13 +156,26 @@ an isolated environment and puts them straight on your `PATH`.
138
156
  │ (render.py) │
139
157
  └──────┬──────────┬────┘
140
158
  ▼ ▼
141
- tui.py (Textual) ui.py (classic)
159
+ tui.py + tui_*.py ui.py + ui_*.py
160
+ (Textual) (classic)
142
161
  ```
143
162
 
144
163
  A single `Renderer` interface (`render.py`) decouples the agent loop from presentation, so
145
164
  the Textual TUI and the classic REPL are two implementations of the same contract rather
146
165
  than two copies of the agent logic.
147
166
 
167
+ Each frontend is a small set of single-purpose modules behind a facade, so the entry module
168
+ stays readable and the parts can be reused or tested on their own:
169
+
170
+ | Frontend | Facade | Split into |
171
+ |---|---|---|
172
+ | Textual | `tui.py` — app, message handlers, input, suggestions | `tui_theme.py` (banner, palette, CSS) · `tui_messages.py` (Message classes, `TuiRenderer`) · `tui_widgets.py` (conversation blocks, input box, modals, `ConfirmBridge`) |
173
+ | Classic | `ui.py` — the `print_*` / `stream_*` renderers | `ui_theme.py` (shared `Console`, palette, banner) · `ui_prompts.py` (permission dialog, input, `EscListener`) |
174
+
175
+ Each facade re-exports the names its split modules define, so `from ui import ...` and
176
+ `from tui import ...` keep working exactly as before.
177
+
178
+
148
179
  ## Modes
149
180
 
150
181
  | Mode | Command | Tools available | Use for |
@@ -235,7 +266,8 @@ models is the first thing to try before changing anything else.
235
266
 
236
267
  ## Roadmap
237
268
 
238
- - [ ] Test suite + CI (guardrails, sandbox path resolution, todo-store invariants)
269
+ - [x] Test suite (`pytest tests/`) — guardrails, sandbox path resolution, todo-store invariants
270
+ - [ ] CI (run the suite on push)
239
271
  - [ ] Docker-based sandbox for shell execution, not just path restriction
240
272
  - [ ] Client/server split — `build_graph()` behind FastAPI/WebSocket, thin streaming client
241
273
  - [ ] PyPI release + prebuilt binaries (PyInstaller) for no-Python-required installs
@@ -76,6 +76,22 @@ closecode
76
76
  `pipx` is recommended over `pip` for the packaged version since it installs CLI tools into
77
77
  an isolated environment and puts them straight on your `PATH`.
78
78
 
79
+ ## Tests
80
+
81
+ ```bash
82
+ pip install -e ".[dev]"
83
+ pytest
84
+ ```
85
+
86
+ 429 tests covering guardrails, sandbox path resolution, undo/checkpoints, background
87
+ processes, todo-store invariants, plan-mode tool filtering, session persistence, config
88
+ permissions, and the search tools. The suite is fully isolated: `config.py` and
89
+ `session.py` are redirected to temp dirs, no test touches the network, and nothing is
90
+ written to your real `~/.closecode`.
91
+
92
+ Some tests are marked `xfail` for **known bugs** rather than fixed behaviour — they
93
+ document a gap and will flip to passing when it's fixed. Run `pytest -rx` to list them.
94
+
79
95
  ## Quick start
80
96
 
81
97
  1. Get a free API key at [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys)
@@ -115,13 +131,26 @@ an isolated environment and puts them straight on your `PATH`.
115
131
  │ (render.py) │
116
132
  └──────┬──────────┬────┘
117
133
  ▼ ▼
118
- tui.py (Textual) ui.py (classic)
134
+ tui.py + tui_*.py ui.py + ui_*.py
135
+ (Textual) (classic)
119
136
  ```
120
137
 
121
138
  A single `Renderer` interface (`render.py`) decouples the agent loop from presentation, so
122
139
  the Textual TUI and the classic REPL are two implementations of the same contract rather
123
140
  than two copies of the agent logic.
124
141
 
142
+ Each frontend is a small set of single-purpose modules behind a facade, so the entry module
143
+ stays readable and the parts can be reused or tested on their own:
144
+
145
+ | Frontend | Facade | Split into |
146
+ |---|---|---|
147
+ | Textual | `tui.py` — app, message handlers, input, suggestions | `tui_theme.py` (banner, palette, CSS) · `tui_messages.py` (Message classes, `TuiRenderer`) · `tui_widgets.py` (conversation blocks, input box, modals, `ConfirmBridge`) |
148
+ | Classic | `ui.py` — the `print_*` / `stream_*` renderers | `ui_theme.py` (shared `Console`, palette, banner) · `ui_prompts.py` (permission dialog, input, `EscListener`) |
149
+
150
+ Each facade re-exports the names its split modules define, so `from ui import ...` and
151
+ `from tui import ...` keep working exactly as before.
152
+
153
+
125
154
  ## Modes
126
155
 
127
156
  | Mode | Command | Tools available | Use for |
@@ -212,7 +241,8 @@ models is the first thing to try before changing anything else.
212
241
 
213
242
  ## Roadmap
214
243
 
215
- - [ ] Test suite + CI (guardrails, sandbox path resolution, todo-store invariants)
244
+ - [x] Test suite (`pytest tests/`) — guardrails, sandbox path resolution, todo-store invariants
245
+ - [ ] CI (run the suite on push)
216
246
  - [ ] Docker-based sandbox for shell execution, not just path restriction
217
247
  - [ ] Client/server split — `build_graph()` behind FastAPI/WebSocket, thin streaming client
218
248
  - [ ] PyPI release + prebuilt binaries (PyInstaller) for no-Python-required installs
@@ -226,4 +256,4 @@ harness root, and add it to the Plan-mode allowlist only if it's genuinely read-
226
256
 
227
257
  ## License
228
258
 
229
- MIT — see [LICENSE](LICENSE).
259
+ MIT — see [LICENSE](LICENSE).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: closecode-ai
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: CloseCode — an agentic terminal coding assistant (LangGraph + OpenRouter + MCP)
5
5
  Author: Om Gite
6
6
  License-Expression: MIT
@@ -20,6 +20,8 @@ Requires-Dist: langchain-openrouter
20
20
  Requires-Dist: rich
21
21
  Requires-Dist: pyfiglet
22
22
  Requires-Dist: textual
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
23
25
 
24
26
  <div align="center">
25
27
 
@@ -99,6 +101,22 @@ closecode
99
101
  `pipx` is recommended over `pip` for the packaged version since it installs CLI tools into
100
102
  an isolated environment and puts them straight on your `PATH`.
101
103
 
104
+ ## Tests
105
+
106
+ ```bash
107
+ pip install -e ".[dev]"
108
+ pytest
109
+ ```
110
+
111
+ 429 tests covering guardrails, sandbox path resolution, undo/checkpoints, background
112
+ processes, todo-store invariants, plan-mode tool filtering, session persistence, config
113
+ permissions, and the search tools. The suite is fully isolated: `config.py` and
114
+ `session.py` are redirected to temp dirs, no test touches the network, and nothing is
115
+ written to your real `~/.closecode`.
116
+
117
+ Some tests are marked `xfail` for **known bugs** rather than fixed behaviour — they
118
+ document a gap and will flip to passing when it's fixed. Run `pytest -rx` to list them.
119
+
102
120
  ## Quick start
103
121
 
104
122
  1. Get a free API key at [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys)
@@ -138,13 +156,26 @@ an isolated environment and puts them straight on your `PATH`.
138
156
  │ (render.py) │
139
157
  └──────┬──────────┬────┘
140
158
  ▼ ▼
141
- tui.py (Textual) ui.py (classic)
159
+ tui.py + tui_*.py ui.py + ui_*.py
160
+ (Textual) (classic)
142
161
  ```
143
162
 
144
163
  A single `Renderer` interface (`render.py`) decouples the agent loop from presentation, so
145
164
  the Textual TUI and the classic REPL are two implementations of the same contract rather
146
165
  than two copies of the agent logic.
147
166
 
167
+ Each frontend is a small set of single-purpose modules behind a facade, so the entry module
168
+ stays readable and the parts can be reused or tested on their own:
169
+
170
+ | Frontend | Facade | Split into |
171
+ |---|---|---|
172
+ | Textual | `tui.py` — app, message handlers, input, suggestions | `tui_theme.py` (banner, palette, CSS) · `tui_messages.py` (Message classes, `TuiRenderer`) · `tui_widgets.py` (conversation blocks, input box, modals, `ConfirmBridge`) |
173
+ | Classic | `ui.py` — the `print_*` / `stream_*` renderers | `ui_theme.py` (shared `Console`, palette, banner) · `ui_prompts.py` (permission dialog, input, `EscListener`) |
174
+
175
+ Each facade re-exports the names its split modules define, so `from ui import ...` and
176
+ `from tui import ...` keep working exactly as before.
177
+
178
+
148
179
  ## Modes
149
180
 
150
181
  | Mode | Command | Tools available | Use for |
@@ -235,7 +266,8 @@ models is the first thing to try before changing anything else.
235
266
 
236
267
  ## Roadmap
237
268
 
238
- - [ ] Test suite + CI (guardrails, sandbox path resolution, todo-store invariants)
269
+ - [x] Test suite (`pytest tests/`) — guardrails, sandbox path resolution, todo-store invariants
270
+ - [ ] CI (run the suite on push)
239
271
  - [ ] Docker-based sandbox for shell execution, not just path restriction
240
272
  - [ ] Client/server split — `build_graph()` behind FastAPI/WebSocket, thin streaming client
241
273
  - [ ] PyPI release + prebuilt binaries (PyInstaller) for no-Python-required installs
@@ -16,10 +16,24 @@ todos.py
16
16
  token_tracker.py
17
17
  tools.py
18
18
  tui.py
19
+ tui_messages.py
20
+ tui_theme.py
21
+ tui_widgets.py
19
22
  ui.py
23
+ ui_prompts.py
24
+ ui_theme.py
20
25
  closecode_ai.egg-info/PKG-INFO
21
26
  closecode_ai.egg-info/SOURCES.txt
22
27
  closecode_ai.egg-info/dependency_links.txt
23
28
  closecode_ai.egg-info/entry_points.txt
24
29
  closecode_ai.egg-info/requires.txt
25
- closecode_ai.egg-info/top_level.txt
30
+ closecode_ai.egg-info/top_level.txt
31
+ tests/test_config.py
32
+ tests/test_guardrails.py
33
+ tests/test_harness.py
34
+ tests/test_llm.py
35
+ tests/test_modes.py
36
+ tests/test_search.py
37
+ tests/test_session.py
38
+ tests/test_todos.py
39
+ tests/test_token_tracker.py
@@ -12,3 +12,6 @@ langchain-openrouter
12
12
  rich
13
13
  pyfiglet
14
14
  textual
15
+
16
+ [dev]
17
+ pytest>=8
@@ -14,4 +14,9 @@ todos
14
14
  token_tracker
15
15
  tools
16
16
  tui
17
+ tui_messages
18
+ tui_theme
19
+ tui_widgets
17
20
  ui
21
+ ui_prompts
22
+ ui_theme
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "closecode-ai"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "CloseCode — an agentic terminal coding assistant (LangGraph + OpenRouter + MCP)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -27,10 +27,23 @@ dependencies = [
27
27
  "textual",
28
28
  ]
29
29
 
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8"]
32
+
30
33
  # `closecode` command -> main:cli_entry
31
34
  [project.scripts]
32
35
  closecode = "main:cli_entry"
33
36
 
37
+ [tool.pytest.ini_options]
38
+ # tests/ imports the app modules by bare name (flat layout), which
39
+ # tests/conftest.py arranges by putting the project root on sys.path.
40
+ testpaths = ["tests"]
41
+ python_files = ["test_*.py"]
42
+ addopts = "-q --strict-markers"
43
+ filterwarnings = [
44
+ "ignore::UserWarning:langchain_core.utils.pydantic",
45
+ ]
46
+
34
47
  # Flat layout: every top-level .py file is a module to ship.
35
48
  [tool.setuptools]
36
49
  py-modules = [
@@ -50,5 +63,10 @@ py-modules = [
50
63
  "token_tracker",
51
64
  "tools",
52
65
  "tui",
66
+ "tui_messages",
67
+ "tui_theme",
68
+ "tui_widgets",
53
69
  "ui",
70
+ "ui_prompts",
71
+ "ui_theme",
54
72
  ]
@@ -0,0 +1,175 @@
1
+ """Tests for config.py — the per-user API key / model store.
2
+
3
+ The file holds a secret, so two properties matter as much as correctness:
4
+ it is created 0600, and a corrupt or hostile file can't crash the launcher.
5
+ Both are tested here against a CLOSECODE_CONFIG_DIR temp dir.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import stat
11
+
12
+ import pytest
13
+
14
+ import config
15
+ from config import (
16
+ CONFIG_DIR_ENV,
17
+ CONFIG_FILENAME,
18
+ KEY_API,
19
+ KEY_MODEL,
20
+ config_dir,
21
+ config_path,
22
+ load_config,
23
+ save_config_value,
24
+ )
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # location
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def test_config_dir_honours_the_env_override(isolated_home):
32
+ assert config_dir() == isolated_home.config_dir
33
+
34
+
35
+ def test_config_path_is_config_json_in_that_dir(isolated_home):
36
+ assert config_path() == isolated_home.config_dir / CONFIG_FILENAME
37
+ assert config_path().name == "config.json"
38
+
39
+
40
+ def test_config_dir_is_created_on_demand(tmp_path, monkeypatch):
41
+ target = tmp_path / "brand" / "new"
42
+ monkeypatch.setenv(CONFIG_DIR_ENV, str(target))
43
+ assert config_dir() == target
44
+ assert target.is_dir()
45
+
46
+
47
+ def test_blank_env_override_falls_back_to_default(monkeypatch, tmp_path):
48
+ """An empty/whitespace override must not produce a path of '' — that would
49
+ write the API key into the current working directory. HOME is redirected so
50
+ the fallback creates a temp dir, not a real ~/.closecode."""
51
+ monkeypatch.setenv("HOME", str(tmp_path))
52
+ monkeypatch.setenv(CONFIG_DIR_ENV, " ")
53
+ assert config_dir() == tmp_path / ".closecode"
54
+ assert config_dir().is_dir()
55
+
56
+
57
+ def test_env_override_expands_a_tilde(monkeypatch, tmp_path):
58
+ monkeypatch.setenv("HOME", str(tmp_path))
59
+ monkeypatch.setenv(CONFIG_DIR_ENV, "~/somewhere")
60
+ assert config_dir() == tmp_path / "somewhere"
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # load_config
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def test_load_missing_file_returns_empty_dict(isolated_home):
68
+ assert load_config() == {}
69
+
70
+
71
+ def test_load_reads_back_saved_values(isolated_home):
72
+ save_config_value(KEY_API, "sk-or-v1-abc")
73
+ save_config_value(KEY_MODEL, "some/model:free")
74
+ assert load_config() == {KEY_API: "sk-or-v1-abc", KEY_MODEL: "some/model:free"}
75
+
76
+
77
+ @pytest.mark.parametrize(
78
+ "content",
79
+ [
80
+ "{not valid json",
81
+ "",
82
+ "null",
83
+ "[]",
84
+ '"a string"',
85
+ "123",
86
+ "\x00\x01binary",
87
+ ],
88
+ )
89
+ def test_load_corrupt_or_non_object_file_returns_empty_dict(isolated_home, content):
90
+ """A mangled config must degrade to 'no config' rather than raising on
91
+ every launch — the user would be locked out of the app entirely."""
92
+ config_path().write_text(content, encoding="utf-8")
93
+ assert load_config() == {}
94
+
95
+
96
+ def test_load_survives_a_directory_in_place_of_the_file(isolated_home):
97
+ config_path().mkdir()
98
+ assert load_config() == {}
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # save_config_value
103
+ # ---------------------------------------------------------------------------
104
+
105
+ def test_save_merges_rather_than_replaces(isolated_home):
106
+ save_config_value(KEY_API, "key-one")
107
+ save_config_value(KEY_MODEL, "model-one")
108
+ data = json.loads(config_path().read_text())
109
+ assert data == {KEY_API: "key-one", KEY_MODEL: "model-one"}
110
+
111
+
112
+ def test_save_overwrites_a_single_key(isolated_home):
113
+ save_config_value(KEY_API, "old")
114
+ save_config_value(KEY_API, "new")
115
+ assert load_config()[KEY_API] == "new"
116
+ assert len(load_config()) == 1
117
+
118
+
119
+ def test_save_returns_the_path_written(isolated_home):
120
+ assert save_config_value(KEY_API, "k") == config_path()
121
+
122
+
123
+ def test_save_recovers_from_a_corrupt_file(isolated_home):
124
+ """A user who hand-edited the file badly should still be able to save."""
125
+ config_path().write_text("{{{", encoding="utf-8")
126
+ save_config_value(KEY_API, "k")
127
+ assert load_config() == {KEY_API: "k"}
128
+
129
+
130
+ def test_save_written_file_is_valid_json_with_trailing_newline(isolated_home):
131
+ save_config_value(KEY_API, "k")
132
+ raw = config_path().read_text(encoding="utf-8")
133
+ assert raw.endswith("\n")
134
+ assert json.loads(raw) == {KEY_API: "k"}
135
+
136
+
137
+ def test_save_preserves_unicode_values(isolated_home):
138
+ save_config_value(KEY_MODEL, "modèle/百万:free")
139
+ assert load_config()[KEY_MODEL] == "modèle/百万:free"
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # permissions — the file holds a secret
144
+ # ---------------------------------------------------------------------------
145
+
146
+ @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
147
+ def test_config_file_is_owner_only(isolated_home):
148
+ save_config_value(KEY_API, "sk-secret")
149
+ mode = stat.S_IMODE(config_path().stat().st_mode)
150
+ assert mode == 0o600, f"expected 0600, got {oct(mode)}"
151
+
152
+
153
+ @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
154
+ def test_config_file_is_not_world_or_group_readable(isolated_home):
155
+ save_config_value(KEY_API, "sk-secret")
156
+ mode = stat.S_IMODE(config_path().stat().st_mode)
157
+ assert not mode & stat.S_IRGRP
158
+ assert not mode & stat.S_IROTH
159
+
160
+
161
+ @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
162
+ def test_resaving_keeps_the_tight_permissions(isolated_home):
163
+ save_config_value(KEY_API, "one")
164
+ save_config_value(KEY_API, "two")
165
+ assert stat.S_IMODE(config_path().stat().st_mode) == 0o600
166
+
167
+
168
+ @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
169
+ def test_permissions_are_tightened_on_an_existing_loose_file(isolated_home):
170
+ """A file left 0644 by an earlier version or a manual edit gets fixed on
171
+ the next save, not just on creation."""
172
+ config_path().write_text("{}", encoding="utf-8")
173
+ os.chmod(config_path(), 0o644)
174
+ save_config_value(KEY_API, "k")
175
+ assert stat.S_IMODE(config_path().stat().st_mode) == 0o600