lemonpie-cli 0.2.0a3__tar.gz → 0.2.0b1__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 (32) hide show
  1. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/.github/workflows/ci.yml +2 -2
  2. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/.github/workflows/release.yml +1 -0
  3. lemonpie_cli-0.2.0b1/.release-please/manifest.json +3 -0
  4. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/CHANGELOG.md +7 -0
  5. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/PKG-INFO +1 -1
  6. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/pyproject.toml +7 -0
  7. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/_version.py +2 -2
  8. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/cli/config_cmd.py +6 -1
  9. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/main.py +3 -8
  10. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/sessions/storage.py +9 -17
  11. lemonpie_cli-0.2.0b1/tests/conftest.py +84 -0
  12. lemonpie_cli-0.2.0b1/tests/test_cli.py +110 -0
  13. lemonpie_cli-0.2.0b1/tests/test_sessions.py +294 -0
  14. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/uv.lock +69 -0
  15. lemonpie_cli-0.2.0a3/.release-please/manifest.json +0 -3
  16. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/.gitignore +0 -0
  17. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/.release-please/config.json +0 -0
  18. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/LICENSE +0 -0
  19. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/README.md +0 -0
  20. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/config.example.json +0 -0
  21. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/docs/design/state-machine.md +0 -0
  22. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/__init__.py +0 -0
  23. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/cli/__init__.py +0 -0
  24. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/cli/parser.py +0 -0
  25. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/cli/ui.py +0 -0
  26. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/config.py +0 -0
  27. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/engine/__init__.py +0 -0
  28. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/engine/models.py +0 -0
  29. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/engine/ollama.py +0 -0
  30. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/paths.py +0 -0
  31. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/sessions/__init__.py +0 -0
  32. {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/time_utils.py +0 -0
@@ -28,5 +28,5 @@ jobs:
28
28
  - name: Verify build artifact
29
29
  run: uv build
30
30
 
31
- # - name: Run tests (Uncomment in Phase 3)
32
- # run: uv run pytest
31
+ - name: Run tests
32
+ run: uv run pytest
@@ -21,6 +21,7 @@ jobs:
21
21
  - uses: googleapis/release-please-action@v4
22
22
  id: release
23
23
  with:
24
+ token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
24
25
  config-file: .release-please/config.json
25
26
  manifest-file: .release-please/manifest.json
26
27
 
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.0-beta.1"
3
+ }
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0-beta.1](https://github.com/famousStratum/LemonPie/compare/v0.2.0-alpha.3...v0.2.0-beta.1) (2026-09-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * eliminate now_ts() double-calls and session ID collisions in session creation ([#27](https://github.com/famousStratum/LemonPie/issues/27)) ([7864136](https://github.com/famousStratum/LemonPie/commit/7864136b6dc200e1e682b61252eadc74e99ac66a))
9
+
3
10
  ## [0.2.0-alpha.3](https://github.com/famousStratum/LemonPie/compare/v0.2.0-alpha.2...v0.2.0-alpha.3) (2026-09-21)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lemonpie-cli
3
- Version: 0.2.0a3
3
+ Version: 0.2.0b1
4
4
  Summary: LemonPie — A lightweight Ollama client for terminal users
5
5
  Project-URL: homepage, https://versicode.com/lemonpie
6
6
  Project-URL: repository, https://github.com/famousStratum/LemonPie
@@ -37,3 +37,10 @@ version-file = "src/lemonpie/_version.py"
37
37
 
38
38
  [tool.hatch.build.targets.wheel]
39
39
  packages = ["src/lemonpie"]
40
+
41
+ [dependency-groups]
42
+ dev = ["pytest>=8.0"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ addopts = "-ra"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0a3'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'a3')
21
+ __version__ = version = '0.2.0b1'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'b1')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -196,7 +196,7 @@ def cmd_show(cfg):
196
196
  return 0
197
197
 
198
198
 
199
- def main():
199
+ def build_parser():
200
200
  parser = argparse.ArgumentParser(prog="lmConfig", description="Manage LemonPie configuration (config.json)")
201
201
  parser.add_argument("--host", help="Set Ollama host URL (http(s)://host:port)")
202
202
  parser.add_argument("--default-model", help="Set default model using alias or full name (must exist in models)")
@@ -205,6 +205,11 @@ def main():
205
205
  parser.add_argument("--list-models", action="store_true", help="List configured model aliases and names")
206
206
  parser.add_argument("--show", action="store_true", help="Print full config.json")
207
207
  parser.add_argument("--force", action="store_true", help="Force overwrite when adding a model")
208
+ return parser
209
+
210
+
211
+ def main():
212
+ parser = build_parser()
208
213
  args = parser.parse_args()
209
214
 
210
215
  cfg = load_config()
@@ -47,7 +47,6 @@ from lemonpie.engine.ollama import (
47
47
  )
48
48
 
49
49
  from lemonpie.sessions.storage import (
50
- create_session,
51
50
  delete_session_by_id,
52
51
  delete_all_sessions,
53
52
  resolve_session,
@@ -134,13 +133,9 @@ def main():
134
133
  if args.prompt:
135
134
  user_prompt = " ".join(args.prompt)
136
135
  turn_ts = now_ts()
137
- if session:
138
- session["history"].append({"role": "user", "content": user_prompt, "ts": turn_ts})
139
- session["updated_at"] = turn_ts
140
- save_session(session_id, session)
141
- else:
142
- session = create_session(user_prompt, model)
143
- session_id = session["id"]
136
+ session["history"].append({"role": "user", "content": user_prompt, "ts": turn_ts})
137
+ session["updated_at"] = turn_ts
138
+ save_session(session_id, session)
144
139
 
145
140
  if not find_by_name(cfg, model):
146
141
  print(f'Configured models do not include "{model}". Use lmConfig to add it or pass a known alias.')
@@ -1,4 +1,4 @@
1
- import glob, json, os, sys
1
+ import glob, json, os, sys, uuid
2
2
  from datetime import datetime, timezone
3
3
 
4
4
  from lemonpie.paths import CURRENT_FILE, SESSIONS_DIR
@@ -99,10 +99,12 @@ def set_title(session, new_title):
99
99
  save_session(session["id"], session)
100
100
  return True
101
101
 
102
- def create_session(prompt, model):
103
- session_id = datetime.now().strftime("%Y%m%d-%H%M%S")
104
- title = default_title(" ".join(prompt)) if prompt else "<no title>"
105
- session = {"id": session_id, "model": model, "title": title, "history": [], "created_at": now_ts(), "updated_at": now_ts()}
102
+ def create_session(prompt, model, title=None):
103
+ session_id = datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]
104
+ if title is None:
105
+ title = default_title(" ".join(prompt)) if prompt else "<no title>"
106
+ ts = now_ts()
107
+ session = {"id": session_id, "model": model, "title": title, "history": [], "created_at": ts, "updated_at": ts}
106
108
  save_session(session_id, session)
107
109
  write_current(session_id)
108
110
  return session
@@ -138,18 +140,8 @@ def update_title(session, model, new_title, prompt=None):
138
140
  if not session:
139
141
  confirm = input(f'No current session. Create a new session with title "{new_title}"? (Y/n): ')
140
142
  if confirm.lower() in ("y", "yes", ""):
141
- session_id = datetime.now().strftime("%Y%m%d-%H%M%S")
142
- session = {
143
- "id": session_id,
144
- "model": model,
145
- "title": new_title,
146
- "history": [],
147
- "created_at": now_ts(),
148
- "updated_at": now_ts()
149
- }
150
- save_session(session_id, session)
151
- write_current(session_id)
152
- print(f'Created new session {session_id} with title "{new_title}".')
143
+ session = create_session(prompt or [], model, title=new_title)
144
+ print(f'Created new session {session["id"]} with title "{new_title}".')
153
145
  else:
154
146
  print("Aborted creating new session.")
155
147
  sys.exit(0)
@@ -0,0 +1,84 @@
1
+ """
2
+ Shared pytest fixtures for LemonPie's test suite.
3
+
4
+ The tricky part here is that `lemonpie.paths` computes CONFIG_FILE / SESSIONS_DIR /
5
+ CURRENT_FILE exactly once, at import time, from $LEMONPIE_CONFIG_DIR and
6
+ $LEMONPIE_SESSIONS_DIR. By the time any test runs, that module has already been
7
+ imported and those names are already bound to real strings. Setting the env vars
8
+ inside a fixture has no effect on values that were already computed.
9
+
10
+ Worse, `lemonpie.config` and `lemonpie.sessions.storage` each did
11
+ `from lemonpie.paths import CONFIG_FILE` / `from lemonpie.paths import CURRENT_FILE, SESSIONS_DIR`
12
+ — a value copy into their own module namespace, not a live reference back to
13
+ `lemonpie.paths`. So patching `lemonpie.paths.CONFIG_FILE` alone would not affect
14
+ `lemonpie.config.CONFIG_FILE`.
15
+
16
+ The `isolated_dirs` fixture below patches every one of those copies directly with
17
+ monkeypatch (which auto-reverts after each test), so each test gets its own throwaway
18
+ config file and sessions directory with no risk of touching the developer's real
19
+ ~/.config/lemonpie or ~/.local/share/lemonpie.
20
+ """
21
+
22
+ import json
23
+
24
+ import pytest
25
+
26
+ import lemonpie.config as config
27
+ import lemonpie.paths as paths
28
+ import lemonpie.sessions.storage as storage
29
+
30
+
31
+ @pytest.fixture
32
+ def isolated_dirs(tmp_path, monkeypatch):
33
+ """Redirect all on-disk LemonPie state into a temp directory for one test."""
34
+ config_dir = tmp_path / "config"
35
+ data_dir = tmp_path / "data"
36
+ sessions_dir = data_dir / "sessions"
37
+ config_dir.mkdir()
38
+ sessions_dir.mkdir(parents=True)
39
+
40
+ config_file = config_dir / "config.json"
41
+ current_file = sessions_dir / ".current"
42
+
43
+ # Keep the env vars themselves in sync too, for any code path that reads
44
+ # them directly (e.g. a future subprocess-based test).
45
+ monkeypatch.setenv("LEMONPIE_CONFIG_DIR", str(config_dir))
46
+ monkeypatch.setenv("LEMONPIE_SESSIONS_DIR", str(sessions_dir))
47
+
48
+ # lemonpie.paths itself
49
+ monkeypatch.setattr(paths, "CONFIG_DIR", str(config_dir))
50
+ monkeypatch.setattr(paths, "CONFIG_FILE", str(config_file))
51
+ monkeypatch.setattr(paths, "DATA_DIR", str(data_dir))
52
+ monkeypatch.setattr(paths, "SESSIONS_DIR", str(sessions_dir))
53
+ monkeypatch.setattr(paths, "CURRENT_FILE", str(current_file))
54
+
55
+ # Every module that imported these names directly (own copies, not live refs)
56
+ monkeypatch.setattr(config, "CONFIG_FILE", str(config_file))
57
+ monkeypatch.setattr(storage, "SESSIONS_DIR", str(sessions_dir))
58
+ monkeypatch.setattr(storage, "CURRENT_FILE", str(current_file))
59
+
60
+ return {
61
+ "config_dir": config_dir,
62
+ "sessions_dir": sessions_dir,
63
+ "config_file": config_file,
64
+ "current_file": current_file,
65
+ }
66
+
67
+
68
+ @pytest.fixture
69
+ def sample_cfg():
70
+ """A minimal, valid config dict: one aliased model, set as default."""
71
+ return {
72
+ "server": "http://127.0.0.1:11434",
73
+ "default": "qwen2.5-coder:3b",
74
+ "models": [
75
+ {"alias": "qwen", "name": "qwen2.5-coder:3b"},
76
+ ],
77
+ }
78
+
79
+
80
+ @pytest.fixture
81
+ def write_cfg(isolated_dirs, sample_cfg):
82
+ """Write sample_cfg to the isolated config file and hand back the dict."""
83
+ isolated_dirs["config_file"].write_text(json.dumps(sample_cfg), encoding="utf-8")
84
+ return sample_cfg
@@ -0,0 +1,110 @@
1
+ """Argument-parsing tests for `lm` (lemonpie.cli.parser) and `lmConfig`
2
+ (lemonpie.cli.config_cmd). These only exercise parser.parse_args() — no config
3
+ files, no sessions, no network — so they don't need the isolated_dirs fixture.
4
+ """
5
+
6
+ import pytest
7
+
8
+ from lemonpie.cli.config_cmd import build_parser as build_lmconfig_parser
9
+ from lemonpie.cli.parser import build_parser as build_lm_parser
10
+
11
+
12
+ class TestLmParser:
13
+ def test_default_no_args(self):
14
+ args = build_lm_parser().parse_args([])
15
+ assert args.model is None
16
+ assert args.new is False
17
+ assert args.session is None
18
+ assert args.list is False
19
+ assert args.delete is None
20
+ assert args.title is None
21
+ assert args.close is False
22
+ assert args.print is False
23
+ assert args.delete_all is False
24
+ assert args.prompt == []
25
+
26
+ def test_prompt_is_collected_as_list(self):
27
+ args = build_lm_parser().parse_args(["tell", "me", "a", "joke"])
28
+ assert args.prompt == ["tell", "me", "a", "joke"]
29
+
30
+ def test_model_flag_short_and_long_forms_agree(self):
31
+ assert build_lm_parser().parse_args(["-m", "qwen"]).model == "qwen"
32
+ assert build_lm_parser().parse_args(["--model", "qwen"]).model == "qwen"
33
+
34
+ def test_new_session_flag_with_prompt(self):
35
+ args = build_lm_parser().parse_args(["-n", "hello"])
36
+ assert args.new is True
37
+ assert args.prompt == ["hello"]
38
+
39
+ def test_list_flag(self):
40
+ assert build_lm_parser().parse_args(["-l"]).list is True
41
+
42
+ def test_delete_takes_a_session_id(self):
43
+ args = build_lm_parser().parse_args(["-d", "20260101-000000"])
44
+ assert args.delete == "20260101-000000"
45
+
46
+ def test_delete_all_is_distinct_from_delete(self):
47
+ args = build_lm_parser().parse_args(["--delete-all"])
48
+ assert args.delete_all is True
49
+ assert args.delete is None
50
+
51
+ def test_title_combined_with_prompt(self):
52
+ args = build_lm_parser().parse_args(["-t", "My title", "hi", "there"])
53
+ assert args.title == "My title"
54
+ assert args.prompt == ["hi", "there"]
55
+
56
+ def test_session_flag_takes_an_id(self):
57
+ args = build_lm_parser().parse_args(["-s", "20260101-000000"])
58
+ assert args.session == "20260101-000000"
59
+
60
+ def test_close_flag(self):
61
+ assert build_lm_parser().parse_args(["-c"]).close is True
62
+
63
+ def test_print_flag(self):
64
+ assert build_lm_parser().parse_args(["-p"]).print is True
65
+
66
+ def test_version_flag_exits_zero_and_prints(self, capsys):
67
+ with pytest.raises(SystemExit) as exc_info:
68
+ build_lm_parser().parse_args(["-v"])
69
+ assert exc_info.value.code == 0
70
+ assert capsys.readouterr().out.startswith("lm ")
71
+
72
+
73
+ class TestLmConfigParser:
74
+ def test_no_args_all_defaults(self):
75
+ args = build_lmconfig_parser().parse_args([])
76
+ assert args.host is None
77
+ assert args.default_model is None
78
+ assert args.add_model is None
79
+ assert args.remove_model is None
80
+ assert args.list_models is False
81
+ assert args.show is False
82
+ assert args.force is False
83
+
84
+ def test_host(self):
85
+ args = build_lmconfig_parser().parse_args(["--host", "http://127.0.0.1:11434"])
86
+ assert args.host == "http://127.0.0.1:11434"
87
+
88
+ def test_add_model_takes_alias_and_name(self):
89
+ args = build_lmconfig_parser().parse_args(["--add-model", "qwen", "qwen2.5-coder:3b"])
90
+ assert args.add_model == ["qwen", "qwen2.5-coder:3b"]
91
+
92
+ def test_add_model_with_force(self):
93
+ args = build_lmconfig_parser().parse_args(
94
+ ["--add-model", "qwen", "qwen2.5-coder:3b", "--force"]
95
+ )
96
+ assert args.force is True
97
+
98
+ def test_remove_model_takes_an_identifier(self):
99
+ args = build_lmconfig_parser().parse_args(["--remove-model", "qwen"])
100
+ assert args.remove_model == "qwen"
101
+
102
+ def test_list_models_flag(self):
103
+ assert build_lmconfig_parser().parse_args(["--list-models"]).list_models is True
104
+
105
+ def test_default_model_takes_an_identifier(self):
106
+ args = build_lmconfig_parser().parse_args(["--default-model", "qwen"])
107
+ assert args.default_model == "qwen"
108
+
109
+ def test_show_flag(self):
110
+ assert build_lmconfig_parser().parse_args(["--show"]).show is True
@@ -0,0 +1,294 @@
1
+ """Tests for session creation, retrieval, and saving logic in
2
+ lemonpie.sessions.storage. Every test uses the isolated_dirs fixture from
3
+ conftest.py so nothing here touches a real ~/.local/share/lemonpie/sessions.
4
+ """
5
+
6
+ import json
7
+
8
+ import pytest
9
+
10
+ from lemonpie.sessions import storage
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # create_session / default_title
15
+ # ---------------------------------------------------------------------------
16
+
17
+ def test_create_session_writes_file_and_sets_current(isolated_dirs):
18
+ session = storage.create_session(["hello", "world"], "qwen2.5-coder:3b")
19
+
20
+ assert session["model"] == "qwen2.5-coder:3b"
21
+ assert session["title"] == "hello world"
22
+ assert session["history"] == []
23
+ assert session["created_at"] == session["updated_at"]
24
+
25
+ saved_path = isolated_dirs["sessions_dir"] / f"{session['id']}.json"
26
+ assert saved_path.exists()
27
+ on_disk = json.loads(saved_path.read_text(encoding="utf-8"))
28
+ assert on_disk["id"] == session["id"]
29
+
30
+ assert storage.read_current() == session["id"]
31
+
32
+
33
+ def test_create_session_without_prompt_uses_placeholder_title(isolated_dirs):
34
+ session = storage.create_session([], "qwen2.5-coder:3b")
35
+ assert session["title"] == "<no title>"
36
+
37
+
38
+ def test_default_title_truncates_long_prompts(isolated_dirs):
39
+ long_prompt = "x" * 80
40
+ title = storage.default_title(long_prompt)
41
+ assert len(title) <= storage.MAX_TITLE_LEN + 1 # +1 for the trailing "…"
42
+ assert title.endswith("…")
43
+
44
+
45
+ def test_default_title_leaves_short_prompts_untouched(isolated_dirs):
46
+ assert storage.default_title("short prompt") == "short prompt"
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # save_session / load_session
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def test_save_and_load_session_round_trip(isolated_dirs):
54
+ session = storage.create_session(["ping"], "qwen")
55
+ session["history"].append(
56
+ {"role": "user", "content": "ping", "ts": "2026-09-22T00:00:00+00:00"}
57
+ )
58
+ storage.save_session(session["id"], session)
59
+
60
+ loaded = storage.load_session(session["id"])
61
+ assert loaded["history"] == session["history"]
62
+ assert loaded["created_at"] == session["created_at"]
63
+
64
+
65
+ def test_load_session_missing_returns_empty_shell(isolated_dirs):
66
+ loaded = storage.load_session("does-not-exist")
67
+ assert loaded == {
68
+ "id": "does-not-exist",
69
+ "title": None,
70
+ "history": [],
71
+ "created_at": None,
72
+ "updated_at": None,
73
+ }
74
+
75
+
76
+ def test_load_session_backfills_missing_timestamps_from_history(isolated_dirs):
77
+ # Simulate an older/hand-edited session file with no created_at/updated_at,
78
+ # written directly rather than via create_session().
79
+ session_id = "20260101-000000"
80
+ raw = {
81
+ "id": session_id,
82
+ "model": "qwen",
83
+ "title": "old session",
84
+ "history": [
85
+ {"role": "user", "content": "hi", "ts": "2026-01-01T00:00:00+00:00"},
86
+ {"role": "assistant", "content": "hello", "ts": "2026-01-01T00:00:05+00:00"},
87
+ ],
88
+ }
89
+ (isolated_dirs["sessions_dir"] / f"{session_id}.json").write_text(
90
+ json.dumps(raw), encoding="utf-8"
91
+ )
92
+
93
+ loaded = storage.load_session(session_id)
94
+ assert loaded["created_at"] == "2026-01-01T00:00:00+00:00"
95
+ assert loaded["updated_at"] == "2026-01-01T00:00:05+00:00"
96
+
97
+
98
+ def test_load_session_backfills_from_mtime_when_history_empty(isolated_dirs):
99
+ session_id = "20260101-000001"
100
+ raw = {"id": session_id, "model": "qwen", "title": "empty", "history": []}
101
+ (isolated_dirs["sessions_dir"] / f"{session_id}.json").write_text(
102
+ json.dumps(raw), encoding="utf-8"
103
+ )
104
+
105
+ loaded = storage.load_session(session_id)
106
+ assert loaded["created_at"] is not None
107
+ assert loaded["updated_at"] == loaded["created_at"]
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # delete_session / delete_all_sessions
112
+ # ---------------------------------------------------------------------------
113
+
114
+ def test_delete_session_removes_file_and_reports_result(isolated_dirs):
115
+ session = storage.create_session(["bye"], "qwen")
116
+ assert storage.delete_session(session["id"]) is True
117
+ assert not (isolated_dirs["sessions_dir"] / f"{session['id']}.json").exists()
118
+ assert storage.delete_session(session["id"]) is False # already gone
119
+
120
+
121
+ def test_create_session_ids_are_unique_even_within_the_same_second(isolated_dirs):
122
+ ids = {storage.create_session([], "qwen")["id"] for _ in range(20)}
123
+ assert len(ids) == 20
124
+
125
+
126
+ def test_delete_all_sessions_clears_dir_and_current(isolated_dirs):
127
+ storage.create_session(["one"], "qwen")
128
+ storage.create_session(["two"], "qwen")
129
+ assert storage.read_current() is not None
130
+
131
+ count = storage.delete_all_sessions()
132
+
133
+ assert count == 2
134
+ assert storage.read_current() is None
135
+ assert list(isolated_dirs["sessions_dir"].glob("*.json")) == []
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # list_sessions
140
+ # ---------------------------------------------------------------------------
141
+
142
+ def test_list_sessions_marks_current_and_ignores_non_json(isolated_dirs):
143
+ s1 = storage.create_session(["first"], "qwen")
144
+ s2 = storage.create_session(["second"], "qwen") # becomes current
145
+ (isolated_dirs["sessions_dir"] / "notes.txt").write_text("ignore me", encoding="utf-8")
146
+
147
+ sessions = storage.list_sessions()
148
+ ids = {row[0] for row in sessions}
149
+ assert ids == {s1["id"], s2["id"]}
150
+
151
+ current_rows = [row for row in sessions if row[4] == "*"]
152
+ assert len(current_rows) == 1
153
+ assert current_rows[0][0] == s2["id"]
154
+
155
+
156
+ def test_list_sessions_skips_invalid_json_without_crashing(isolated_dirs):
157
+ storage.create_session(["ok"], "qwen")
158
+ (isolated_dirs["sessions_dir"] / "corrupt.json").write_text(
159
+ "{not valid json", encoding="utf-8"
160
+ )
161
+
162
+ sessions = storage.list_sessions()
163
+ assert len(sessions) == 1
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # current-session pointer
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def test_write_read_clear_current(isolated_dirs):
171
+ assert storage.read_current() is None
172
+ storage.write_current("abc")
173
+ assert storage.read_current() == "abc"
174
+ storage.clear_current()
175
+ assert storage.read_current() is None
176
+
177
+
178
+ def test_close_session_clears_current(isolated_dirs):
179
+ storage.write_current("abc")
180
+ storage.close_session()
181
+ assert storage.read_current() is None
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # set_title
186
+ # ---------------------------------------------------------------------------
187
+
188
+ def test_set_title_rejects_overlong_title(isolated_dirs):
189
+ session = storage.create_session(["hi"], "qwen")
190
+ too_long = "x" * (storage.MAX_TITLE_LEN + 1)
191
+ assert storage.set_title(session, too_long) is False
192
+ reloaded = storage.load_session(session["id"])
193
+ assert reloaded["title"] != too_long
194
+
195
+
196
+ def test_set_title_updates_and_persists(isolated_dirs):
197
+ session = storage.create_session(["hi"], "qwen")
198
+ assert storage.set_title(session, "New title") is True
199
+ reloaded = storage.load_session(session["id"])
200
+ assert reloaded["title"] == "New title"
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # resolve_session
205
+ # ---------------------------------------------------------------------------
206
+
207
+ class DummyArgs:
208
+ """Minimal stand-in for the argparse.Namespace fields resolve_session reads."""
209
+
210
+ def __init__(self, new=False, session=None, prompt=None):
211
+ self.new = new
212
+ self.session = session
213
+ self.prompt = prompt or []
214
+
215
+
216
+ def test_resolve_session_new_flag_creates_session(isolated_dirs):
217
+ args = DummyArgs(new=True, prompt=["fresh", "start"])
218
+ session = storage.resolve_session(args, "qwen", current_id=None)
219
+ assert session["title"] == "fresh start"
220
+
221
+
222
+ def test_resolve_session_with_explicit_session_id_applies_model_and_sets_current(isolated_dirs):
223
+ existing = storage.create_session(["first"], "qwen")
224
+ storage.clear_current()
225
+
226
+ args = DummyArgs(session=existing["id"])
227
+ session = storage.resolve_session(args, "qwen2.5-coder:1.5b", current_id=None)
228
+
229
+ assert session["id"] == existing["id"]
230
+ assert session["model"] == "qwen2.5-coder:1.5b"
231
+ assert storage.read_current() == existing["id"]
232
+
233
+
234
+ def test_resolve_session_falls_back_to_current_id(isolated_dirs):
235
+ existing = storage.create_session(["first"], "qwen")
236
+ args = DummyArgs()
237
+ session = storage.resolve_session(args, "qwen", current_id=existing["id"])
238
+ assert session["id"] == existing["id"]
239
+
240
+
241
+ def test_resolve_session_creates_when_nothing_else_applies(isolated_dirs):
242
+ args = DummyArgs(prompt=["brand", "new"])
243
+ session = storage.resolve_session(args, "qwen", current_id=None)
244
+ assert session["title"] == "brand new"
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # functions that call input() / sys.exit()
249
+ # ---------------------------------------------------------------------------
250
+
251
+ def test_delete_session_by_id_confirmed_removes_file(isolated_dirs, monkeypatch):
252
+ session = storage.create_session(["gone soon"], "qwen")
253
+ monkeypatch.setattr("builtins.input", lambda _: "y")
254
+ storage.delete_session_by_id(session["id"])
255
+ assert not (isolated_dirs["sessions_dir"] / f"{session['id']}.json").exists()
256
+
257
+
258
+ def test_delete_session_by_id_aborted_keeps_file(isolated_dirs, monkeypatch):
259
+ session = storage.create_session(["stay"], "qwen")
260
+ monkeypatch.setattr("builtins.input", lambda _: "n")
261
+ storage.delete_session_by_id(session["id"])
262
+ assert (isolated_dirs["sessions_dir"] / f"{session['id']}.json").exists()
263
+
264
+
265
+ def test_update_title_on_existing_session_without_prompt_exits_zero(isolated_dirs):
266
+ session = storage.create_session(["hi"], "qwen")
267
+ with pytest.raises(SystemExit) as exc_info:
268
+ storage.update_title(session, "qwen", "New title", prompt=None)
269
+ assert exc_info.value.code == 0
270
+ assert storage.load_session(session["id"])["title"] == "New title"
271
+
272
+
273
+ def test_update_title_on_existing_session_with_prompt_returns_session(isolated_dirs):
274
+ session = storage.create_session(["hi"], "qwen")
275
+ result = storage.update_title(session, "qwen", "New title", prompt=["more"])
276
+ assert result["title"] == "New title"
277
+
278
+
279
+ def test_update_title_with_no_session_confirmed_creates_one(isolated_dirs, monkeypatch):
280
+ monkeypatch.setattr("builtins.input", lambda _: "y")
281
+ with pytest.raises(SystemExit):
282
+ storage.update_title(None, "qwen", "Brand new", prompt=None)
283
+ sessions = storage.list_sessions()
284
+ assert any(row[1] == "Brand new" for row in sessions)
285
+
286
+ created = storage.load_session(storage.read_current())
287
+ assert created["created_at"] == created["updated_at"]
288
+
289
+
290
+ def test_update_title_with_no_session_aborted_exits_zero(isolated_dirs, monkeypatch):
291
+ monkeypatch.setattr("builtins.input", lambda _: "n")
292
+ with pytest.raises(SystemExit) as exc_info:
293
+ storage.update_title(None, "qwen", "Nope", prompt=None)
294
+ assert exc_info.value.code == 0
@@ -33,6 +33,15 @@ wheels = [
33
33
  { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
34
34
  ]
35
35
 
36
+ [[package]]
37
+ name = "colorama"
38
+ version = "0.4.6"
39
+ source = { registry = "https://pypi.org/simple" }
40
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
41
+ wheels = [
42
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
43
+ ]
44
+
36
45
  [[package]]
37
46
  name = "h11"
38
47
  version = "0.16.0"
@@ -79,6 +88,15 @@ wheels = [
79
88
  { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
80
89
  ]
81
90
 
91
+ [[package]]
92
+ name = "iniconfig"
93
+ version = "2.3.0"
94
+ source = { registry = "https://pypi.org/simple" }
95
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
96
+ wheels = [
97
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
98
+ ]
99
+
82
100
  [[package]]
83
101
  name = "lemonpie-cli"
84
102
  source = { editable = "." }
@@ -88,6 +106,11 @@ dependencies = [
88
106
  { name = "platformdirs" },
89
107
  ]
90
108
 
109
+ [package.dev-dependencies]
110
+ dev = [
111
+ { name = "pytest" },
112
+ ]
113
+
91
114
  [package.metadata]
92
115
  requires-dist = [
93
116
  { name = "httpx" },
@@ -95,6 +118,9 @@ requires-dist = [
95
118
  { name = "platformdirs" },
96
119
  ]
97
120
 
121
+ [package.metadata.requires-dev]
122
+ dev = [{ name = "pytest", specifier = ">=8.0" }]
123
+
98
124
  [[package]]
99
125
  name = "ollama"
100
126
  version = "0.6.2"
@@ -108,6 +134,15 @@ wheels = [
108
134
  { url = "https://files.pythonhosted.org/packages/c4/ab/d6722beeb2d10f7a3b9ff49375708904fde18f82b5609a0bc4aeb5996a4d/ollama-0.6.2-py3-none-any.whl", hash = "sha256:3ad7daab28e5a973445c36a73882a3ef698c2ebb00e21e308652741577509f7d", size = 15115, upload-time = "2026-04-29T21:21:13.794Z" },
109
135
  ]
110
136
 
137
+ [[package]]
138
+ name = "packaging"
139
+ version = "26.3"
140
+ source = { registry = "https://pypi.org/simple" }
141
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
142
+ wheels = [
143
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
144
+ ]
145
+
111
146
  [[package]]
112
147
  name = "platformdirs"
113
148
  version = "4.11.11"
@@ -117,6 +152,15 @@ wheels = [
117
152
  { url = "https://files.pythonhosted.org/packages/39/be/3ae887e84c38c4dd1c549cd5825adc93f48069a94fe1cb8ac52a9da16f15/platformdirs-4.11.11-py3-none-any.whl", hash = "sha256:972ea6b2b387155a536226a0750e46923d731d459a387c4a255c583ed2f64547", size = 24992, upload-time = "2026-09-19T01:18:45.825Z" },
118
153
  ]
119
154
 
155
+ [[package]]
156
+ name = "pluggy"
157
+ version = "1.6.0"
158
+ source = { registry = "https://pypi.org/simple" }
159
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
160
+ wheels = [
161
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
162
+ ]
163
+
120
164
  [[package]]
121
165
  name = "pydantic"
122
166
  version = "2.13.5"
@@ -234,6 +278,31 @@ wheels = [
234
278
  { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" },
235
279
  ]
236
280
 
281
+ [[package]]
282
+ name = "pygments"
283
+ version = "2.21.0"
284
+ source = { registry = "https://pypi.org/simple" }
285
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
286
+ wheels = [
287
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
288
+ ]
289
+
290
+ [[package]]
291
+ name = "pytest"
292
+ version = "9.1.1"
293
+ source = { registry = "https://pypi.org/simple" }
294
+ dependencies = [
295
+ { name = "colorama", marker = "sys_platform == 'win32'" },
296
+ { name = "iniconfig" },
297
+ { name = "packaging" },
298
+ { name = "pluggy" },
299
+ { name = "pygments" },
300
+ ]
301
+ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
302
+ wheels = [
303
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
304
+ ]
305
+
237
306
  [[package]]
238
307
  name = "typing-extensions"
239
308
  version = "4.16.0"
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.2.0-alpha.3"
3
- }
File without changes
File without changes