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.
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/PKG-INFO +35 -3
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/README.md +33 -3
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/PKG-INFO +35 -3
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/SOURCES.txt +15 -1
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/requires.txt +3 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/top_level.txt +5 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/pyproject.toml +19 -1
- closecode_ai-0.5.0/tests/test_config.py +175 -0
- closecode_ai-0.5.0/tests/test_guardrails.py +357 -0
- closecode_ai-0.5.0/tests/test_harness.py +678 -0
- closecode_ai-0.5.0/tests/test_llm.py +332 -0
- closecode_ai-0.5.0/tests/test_modes.py +262 -0
- closecode_ai-0.5.0/tests/test_search.py +274 -0
- closecode_ai-0.5.0/tests/test_session.py +395 -0
- closecode_ai-0.5.0/tests/test_todos.py +271 -0
- closecode_ai-0.5.0/tests/test_token_tracker.py +241 -0
- closecode_ai-0.5.0/tui.py +372 -0
- closecode_ai-0.5.0/tui_messages.py +167 -0
- closecode_ai-0.5.0/tui_theme.py +170 -0
- closecode_ai-0.5.0/tui_widgets.py +421 -0
- closecode_ai-0.5.0/ui.py +272 -0
- closecode_ai-0.5.0/ui_prompts.py +180 -0
- closecode_ai-0.5.0/ui_theme.py +79 -0
- closecode_ai-0.4.0/tui.py +0 -1047
- closecode_ai-0.4.0/ui.py +0 -463
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/agent.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/dependency_links.txt +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/closecode_ai.egg-info/entry_points.txt +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/config.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/debug_response.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/guardrails.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/harness.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/llm.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/main.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/mcp_tools.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/modes.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/render.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/search.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/session.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/setup.cfg +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/todos.py +0 -0
- {closecode_ai-0.4.0 → closecode_ai-0.5.0}/token_tracker.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
- [
|
|
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
|
-
|
|
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
|
-
- [
|
|
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.
|
|
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
|
-
|
|
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
|
-
- [
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "closecode-ai"
|
|
7
|
-
version = "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
|