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.
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/.github/workflows/ci.yml +2 -2
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/.github/workflows/release.yml +1 -0
- lemonpie_cli-0.2.0b1/.release-please/manifest.json +3 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/CHANGELOG.md +7 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/PKG-INFO +1 -1
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/pyproject.toml +7 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/_version.py +2 -2
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/cli/config_cmd.py +6 -1
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/main.py +3 -8
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/sessions/storage.py +9 -17
- lemonpie_cli-0.2.0b1/tests/conftest.py +84 -0
- lemonpie_cli-0.2.0b1/tests/test_cli.py +110 -0
- lemonpie_cli-0.2.0b1/tests/test_sessions.py +294 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/uv.lock +69 -0
- lemonpie_cli-0.2.0a3/.release-please/manifest.json +0 -3
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/.gitignore +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/.release-please/config.json +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/LICENSE +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/README.md +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/config.example.json +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/docs/design/state-machine.md +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/__init__.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/cli/__init__.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/cli/parser.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/cli/ui.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/config.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/engine/__init__.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/engine/models.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/engine/ollama.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/paths.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/sessions/__init__.py +0 -0
- {lemonpie_cli-0.2.0a3 → lemonpie_cli-0.2.0b1}/src/lemonpie/time_utils.py +0 -0
|
@@ -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.
|
|
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
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2, 0, '
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|