ventilatepro-cli 0.1.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.
- ventilatepro_cli-0.1.0/PKG-INFO +49 -0
- ventilatepro_cli-0.1.0/README.md +35 -0
- ventilatepro_cli-0.1.0/pyproject.toml +31 -0
- ventilatepro_cli-0.1.0/setup.cfg +4 -0
- ventilatepro_cli-0.1.0/tests/test_cli.py +171 -0
- ventilatepro_cli-0.1.0/tests/test_config.py +24 -0
- ventilatepro_cli-0.1.0/ventilatepro/__init__.py +5 -0
- ventilatepro_cli-0.1.0/ventilatepro/__main__.py +5 -0
- ventilatepro_cli-0.1.0/ventilatepro/api_client.py +90 -0
- ventilatepro_cli-0.1.0/ventilatepro/auth.py +56 -0
- ventilatepro_cli-0.1.0/ventilatepro/cli.py +228 -0
- ventilatepro_cli-0.1.0/ventilatepro/config.py +148 -0
- ventilatepro_cli-0.1.0/ventilatepro/notes.py +151 -0
- ventilatepro_cli-0.1.0/ventilatepro/queue.py +177 -0
- ventilatepro_cli-0.1.0/ventilatepro_cli.egg-info/PKG-INFO +49 -0
- ventilatepro_cli-0.1.0/ventilatepro_cli.egg-info/SOURCES.txt +18 -0
- ventilatepro_cli-0.1.0/ventilatepro_cli.egg-info/dependency_links.txt +1 -0
- ventilatepro_cli-0.1.0/ventilatepro_cli.egg-info/entry_points.txt +2 -0
- ventilatepro_cli-0.1.0/ventilatepro_cli.egg-info/requires.txt +4 -0
- ventilatepro_cli-0.1.0/ventilatepro_cli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ventilatepro-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: VentilatePro CLI
|
|
5
|
+
Project-URL: Homepage, https://ventilatepro.com/exam/cli/
|
|
6
|
+
Project-URL: Documentation, https://ventilatepro.com/exam/cli/
|
|
7
|
+
Project-URL: Support, https://ventilatepro.com/exam/support/
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: typer>=0.16.0
|
|
11
|
+
Requires-Dist: requests>=2.32.0
|
|
12
|
+
Requires-Dist: keyring>=25.6.0
|
|
13
|
+
Requires-Dist: platformdirs>=4.3.0
|
|
14
|
+
|
|
15
|
+
# VentilatePro CLI
|
|
16
|
+
|
|
17
|
+
VentilatePro CLI is a local command-line client for authenticating to VentilatePro and creating or syncing notes through the scoped CLI API.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Published releases install from PyPI:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pipx install ventilatepro-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Upgrade an existing install:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pipx upgrade ventilatepro-cli
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Local development install:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd ventilatepro-cli
|
|
37
|
+
pip install -e .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
ventilatepro auth login --url http://localhost:8000
|
|
44
|
+
ventilatepro projects list
|
|
45
|
+
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
46
|
+
ventilatepro notes sync
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
See the hosted install guide at `https://ventilatepro.com/exam/cli/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# VentilatePro CLI
|
|
2
|
+
|
|
3
|
+
VentilatePro CLI is a local command-line client for authenticating to VentilatePro and creating or syncing notes through the scoped CLI API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Published releases install from PyPI:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install ventilatepro-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Upgrade an existing install:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pipx upgrade ventilatepro-cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Local development install:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd ventilatepro-cli
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ventilatepro auth login --url http://localhost:8000
|
|
30
|
+
ventilatepro projects list
|
|
31
|
+
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
32
|
+
ventilatepro notes sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
See the hosted install guide at `https://ventilatepro.com/exam/cli/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ventilatepro-cli"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "VentilatePro CLI"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"typer>=0.16.0",
|
|
13
|
+
"requests>=2.32.0",
|
|
14
|
+
"keyring>=25.6.0",
|
|
15
|
+
"platformdirs>=4.3.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://ventilatepro.com/exam/cli/"
|
|
20
|
+
Documentation = "https://ventilatepro.com/exam/cli/"
|
|
21
|
+
Support = "https://ventilatepro.com/exam/support/"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
ventilatepro = "ventilatepro.cli:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["."]
|
|
28
|
+
include = ["ventilatepro*"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.dynamic]
|
|
31
|
+
version = {attr = "ventilatepro.__version__"}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest import mock
|
|
6
|
+
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from ventilatepro import auth, config
|
|
10
|
+
from ventilatepro.api_client import ApiError, NetworkError
|
|
11
|
+
from ventilatepro.cli import app
|
|
12
|
+
from ventilatepro.queue import QueueStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CliTests(unittest.TestCase):
|
|
16
|
+
def setUp(self):
|
|
17
|
+
self.runner = CliRunner()
|
|
18
|
+
|
|
19
|
+
def test_status_json_reports_not_authenticated(self):
|
|
20
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
21
|
+
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
22
|
+
os.environ[config.DATA_ENV_VAR] = tmpdir
|
|
23
|
+
try:
|
|
24
|
+
result = self.runner.invoke(app, ["auth", "status", "--json"])
|
|
25
|
+
self.assertEqual(result.exit_code, 0)
|
|
26
|
+
payload = json.loads(result.stdout)
|
|
27
|
+
self.assertFalse(payload["authenticated"])
|
|
28
|
+
finally:
|
|
29
|
+
os.environ.pop(config.CONFIG_ENV_VAR, None)
|
|
30
|
+
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
31
|
+
|
|
32
|
+
def test_notes_create_queues_on_network_error(self):
|
|
33
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
34
|
+
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
35
|
+
os.environ[config.DATA_ENV_VAR] = tmpdir
|
|
36
|
+
try:
|
|
37
|
+
config.set_base_url("http://localhost:8000")
|
|
38
|
+
with mock.patch.object(config, "_try_get_keyring", return_value=None):
|
|
39
|
+
config.save_token("vpcli_demo_secret")
|
|
40
|
+
|
|
41
|
+
fake_client = mock.Mock()
|
|
42
|
+
fake_client.create_note.side_effect = NetworkError("connection dropped")
|
|
43
|
+
|
|
44
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
45
|
+
result = self.runner.invoke(
|
|
46
|
+
app,
|
|
47
|
+
["notes", "create", "--project", "1", "--body", "Captured from CLI"],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
self.assertEqual(result.exit_code, 0)
|
|
51
|
+
queue_store = QueueStore()
|
|
52
|
+
queued = queue_store.list_unsynced()
|
|
53
|
+
self.assertEqual(len(queued), 1)
|
|
54
|
+
self.assertEqual(queued[0].sync_state, "pending")
|
|
55
|
+
finally:
|
|
56
|
+
os.environ.pop(config.CONFIG_ENV_VAR, None)
|
|
57
|
+
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
58
|
+
|
|
59
|
+
def test_notes_create_does_not_queue_validation_errors(self):
|
|
60
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
61
|
+
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
62
|
+
os.environ[config.DATA_ENV_VAR] = tmpdir
|
|
63
|
+
try:
|
|
64
|
+
config.set_base_url("http://localhost:8000")
|
|
65
|
+
with mock.patch.object(config, "_try_get_keyring", return_value=None):
|
|
66
|
+
config.save_token("vpcli_demo_secret")
|
|
67
|
+
|
|
68
|
+
fake_client = mock.Mock()
|
|
69
|
+
fake_client.create_note.side_effect = ApiError(400, "Validation failed")
|
|
70
|
+
|
|
71
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
72
|
+
result = self.runner.invoke(
|
|
73
|
+
app,
|
|
74
|
+
["notes", "create", "--project", "1", "--body", "Captured from CLI"],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
78
|
+
queue_store = QueueStore()
|
|
79
|
+
self.assertEqual(queue_store.count(), 0)
|
|
80
|
+
finally:
|
|
81
|
+
os.environ.pop(config.CONFIG_ENV_VAR, None)
|
|
82
|
+
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
83
|
+
|
|
84
|
+
def test_notes_create_derives_title_from_first_non_empty_body_line(self):
|
|
85
|
+
fake_client = mock.Mock()
|
|
86
|
+
fake_client.create_note.return_value = {
|
|
87
|
+
"note": {"note_ref": "general-info:1"},
|
|
88
|
+
"audit_entry_id": 12,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
92
|
+
result = self.runner.invoke(
|
|
93
|
+
app,
|
|
94
|
+
["notes", "create", "--project", "1", "--body", "\n\n First captured line \nSecond line"],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
self.assertEqual(result.exit_code, 0)
|
|
98
|
+
payload = fake_client.create_note.call_args.args[0]
|
|
99
|
+
self.assertEqual(payload["title"], "First captured line")
|
|
100
|
+
self.assertEqual(payload["body"], "\n\n First captured line \nSecond line")
|
|
101
|
+
|
|
102
|
+
def test_notes_sync_retries_queued_notes_and_marks_them_synced(self):
|
|
103
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
104
|
+
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
105
|
+
os.environ[config.DATA_ENV_VAR] = tmpdir
|
|
106
|
+
try:
|
|
107
|
+
queue_store = QueueStore()
|
|
108
|
+
queued = queue_store.enqueue(
|
|
109
|
+
project_id=1,
|
|
110
|
+
title="Queued note",
|
|
111
|
+
body="Queued body",
|
|
112
|
+
tags=["cli"],
|
|
113
|
+
captured_at="2026-03-10T10:00:00-07:00",
|
|
114
|
+
idempotency_key="queued-note-1",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
fake_client = mock.Mock()
|
|
118
|
+
fake_client.create_note.return_value = {
|
|
119
|
+
"note": {"note_ref": "general-info:99"},
|
|
120
|
+
"audit_entry_id": 25,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
124
|
+
result = self.runner.invoke(app, ["notes", "sync", "--project", "1", "--json"])
|
|
125
|
+
|
|
126
|
+
self.assertEqual(result.exit_code, 0)
|
|
127
|
+
payload = json.loads(result.stdout)
|
|
128
|
+
self.assertEqual(payload["total"], 1)
|
|
129
|
+
self.assertEqual(payload["synced"], 1)
|
|
130
|
+
self.assertEqual(payload["failed"], 0)
|
|
131
|
+
self.assertEqual(payload["results"][0]["note_ref"], "general-info:99")
|
|
132
|
+
|
|
133
|
+
synced = queue_store.get(queued.local_id)
|
|
134
|
+
self.assertEqual(synced.sync_state, "synced")
|
|
135
|
+
self.assertEqual(synced.server_note_ref, "general-info:99")
|
|
136
|
+
fake_client.create_note.assert_called_once_with(
|
|
137
|
+
{
|
|
138
|
+
"project": 1,
|
|
139
|
+
"title": "Queued note",
|
|
140
|
+
"body": "Queued body",
|
|
141
|
+
"tags": ["cli"],
|
|
142
|
+
"captured_at": "2026-03-10T10:00:00-07:00",
|
|
143
|
+
},
|
|
144
|
+
idempotency_key="queued-note-1",
|
|
145
|
+
)
|
|
146
|
+
finally:
|
|
147
|
+
os.environ.pop(config.CONFIG_ENV_VAR, None)
|
|
148
|
+
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
149
|
+
|
|
150
|
+
def test_notes_list_json_outputs_server_payload(self):
|
|
151
|
+
fake_client = mock.Mock()
|
|
152
|
+
fake_client.list_notes.return_value = {
|
|
153
|
+
"project_id": 1,
|
|
154
|
+
"count": 1,
|
|
155
|
+
"limit": 20,
|
|
156
|
+
"offset": 0,
|
|
157
|
+
"results": [
|
|
158
|
+
{
|
|
159
|
+
"note_ref": "general-info:1",
|
|
160
|
+
"title": "CLI Note",
|
|
161
|
+
"type": "general-info",
|
|
162
|
+
"source": "cli",
|
|
163
|
+
}
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
167
|
+
result = self.runner.invoke(app, ["notes", "list", "--project", "1", "--json"])
|
|
168
|
+
|
|
169
|
+
self.assertEqual(result.exit_code, 0)
|
|
170
|
+
payload = json.loads(result.stdout)
|
|
171
|
+
self.assertEqual(payload["results"][0]["note_ref"], "general-info:1")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest import mock
|
|
6
|
+
|
|
7
|
+
from ventilatepro import config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigTests(unittest.TestCase):
|
|
11
|
+
def test_token_falls_back_to_file_storage_when_keyring_is_unavailable(self):
|
|
12
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
13
|
+
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
14
|
+
os.environ[config.DATA_ENV_VAR] = tmpdir
|
|
15
|
+
try:
|
|
16
|
+
with mock.patch.object(config, "_try_get_keyring", return_value=None):
|
|
17
|
+
storage = config.save_token("vpcli_demo_secret")
|
|
18
|
+
self.assertEqual(storage, "file")
|
|
19
|
+
self.assertEqual(config.get_token(), "vpcli_demo_secret")
|
|
20
|
+
payload = json.loads(config.get_config_path().read_text(encoding="utf-8"))
|
|
21
|
+
self.assertEqual(payload["token_storage"], "file")
|
|
22
|
+
finally:
|
|
23
|
+
os.environ.pop(config.CONFIG_ENV_VAR, None)
|
|
24
|
+
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiError(Exception):
|
|
9
|
+
def __init__(self, status_code: int, message: str, payload: Any = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self.payload = payload
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NetworkError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_error_message(payload: Any, fallback: str) -> str:
|
|
20
|
+
if isinstance(payload, dict):
|
|
21
|
+
if isinstance(payload.get("detail"), str):
|
|
22
|
+
return payload["detail"]
|
|
23
|
+
error = payload.get("error")
|
|
24
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
25
|
+
return error["message"]
|
|
26
|
+
return " ".join(str(value) for value in payload.values() if value)
|
|
27
|
+
if isinstance(payload, str) and payload.strip():
|
|
28
|
+
return payload
|
|
29
|
+
return fallback
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiClient:
|
|
33
|
+
def __init__(self, base_url: str, token: Optional[str] = None, timeout: int = 15):
|
|
34
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
35
|
+
self.token = token
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def request(self, method: str, path: str, *, json_payload: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None):
|
|
39
|
+
final_headers = {
|
|
40
|
+
"Accept": "application/json",
|
|
41
|
+
}
|
|
42
|
+
if headers:
|
|
43
|
+
final_headers.update(headers)
|
|
44
|
+
if self.token:
|
|
45
|
+
final_headers["Authorization"] = f"Bearer {self.token}"
|
|
46
|
+
try:
|
|
47
|
+
response = requests.request(
|
|
48
|
+
method=method,
|
|
49
|
+
url=f"{self.base_url}{path}",
|
|
50
|
+
json=json_payload,
|
|
51
|
+
headers=final_headers,
|
|
52
|
+
timeout=self.timeout,
|
|
53
|
+
)
|
|
54
|
+
except requests.RequestException as exc:
|
|
55
|
+
raise NetworkError(str(exc)) from exc
|
|
56
|
+
|
|
57
|
+
payload: Any = None
|
|
58
|
+
if response.content:
|
|
59
|
+
try:
|
|
60
|
+
payload = response.json()
|
|
61
|
+
except ValueError:
|
|
62
|
+
payload = response.text
|
|
63
|
+
|
|
64
|
+
if not response.ok:
|
|
65
|
+
raise ApiError(
|
|
66
|
+
status_code=response.status_code,
|
|
67
|
+
message=extract_error_message(payload, f"Request failed with status {response.status_code}."),
|
|
68
|
+
payload=payload,
|
|
69
|
+
)
|
|
70
|
+
return payload if payload is not None else {}
|
|
71
|
+
|
|
72
|
+
def auth_me(self):
|
|
73
|
+
return self.request("GET", "/api/cli/auth/me/")
|
|
74
|
+
|
|
75
|
+
def projects_list(self):
|
|
76
|
+
return self.request("GET", "/api/cli/projects/")
|
|
77
|
+
|
|
78
|
+
def create_note(self, payload: Dict[str, Any], *, idempotency_key: str):
|
|
79
|
+
return self.request(
|
|
80
|
+
"POST",
|
|
81
|
+
"/api/cli/notes/",
|
|
82
|
+
json_payload=payload,
|
|
83
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def list_notes(self, project: int, *, limit: int = 20, offset: int = 0):
|
|
87
|
+
return self.request("GET", f"/api/cli/notes/?project={project}&limit={limit}&offset={offset}")
|
|
88
|
+
|
|
89
|
+
def show_note(self, note_ref: str, *, project: int):
|
|
90
|
+
return self.request("GET", f"/api/cli/notes/{note_ref}/?project={project}")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from getpass import getpass
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
|
|
6
|
+
from .api_client import ApiClient
|
|
7
|
+
from .config import clear_auth, get_base_url, get_token, save_token, set_base_url
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def login(base_url: str | None, token: str | None):
|
|
11
|
+
normalized_url = (base_url or get_base_url() or "").strip().rstrip("/")
|
|
12
|
+
if not normalized_url:
|
|
13
|
+
raise ValueError("API base URL is required. Pass --url or log in with a saved URL.")
|
|
14
|
+
|
|
15
|
+
resolved_token = token or getpass("Personal access token: ").strip()
|
|
16
|
+
if not resolved_token:
|
|
17
|
+
raise ValueError("Personal access token is required.")
|
|
18
|
+
|
|
19
|
+
client = ApiClient(normalized_url, resolved_token)
|
|
20
|
+
identity = client.auth_me()
|
|
21
|
+
token_storage = save_token(resolved_token)
|
|
22
|
+
set_base_url(normalized_url)
|
|
23
|
+
return {
|
|
24
|
+
"status": "authenticated",
|
|
25
|
+
"base_url": normalized_url,
|
|
26
|
+
"token_storage": token_storage,
|
|
27
|
+
"user": identity,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def status():
|
|
32
|
+
base_url = get_base_url()
|
|
33
|
+
token = get_token()
|
|
34
|
+
return {
|
|
35
|
+
"authenticated": bool(base_url and token),
|
|
36
|
+
"base_url": base_url,
|
|
37
|
+
"token_present": bool(token),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_authenticated_client() -> Tuple[ApiClient, str]:
|
|
42
|
+
base_url = get_base_url()
|
|
43
|
+
token = get_token()
|
|
44
|
+
if not base_url or not token:
|
|
45
|
+
raise ValueError("Not authenticated. Run `ventilatepro auth login` first.")
|
|
46
|
+
return ApiClient(base_url, token), base_url
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def whoami():
|
|
50
|
+
client, _ = get_authenticated_client()
|
|
51
|
+
return client.auth_me()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def logout():
|
|
55
|
+
clear_auth()
|
|
56
|
+
return {"status": "logged_out"}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from . import auth, notes
|
|
8
|
+
from .api_client import ApiError, NetworkError
|
|
9
|
+
from .queue import QueueStore
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="VentilatePro CLI")
|
|
12
|
+
auth_app = typer.Typer(help="Authentication commands")
|
|
13
|
+
projects_app = typer.Typer(help="Project commands")
|
|
14
|
+
notes_app = typer.Typer(help="Notes commands")
|
|
15
|
+
|
|
16
|
+
app.add_typer(auth_app, name="auth")
|
|
17
|
+
app.add_typer(projects_app, name="projects")
|
|
18
|
+
app.add_typer(notes_app, name="notes")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
app()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def emit(payload, *, json_output: bool, lines: list[str]):
|
|
26
|
+
if json_output:
|
|
27
|
+
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
28
|
+
return
|
|
29
|
+
for line in lines:
|
|
30
|
+
typer.echo(line)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def emit_error(message: str, *, json_output: bool):
|
|
34
|
+
if json_output:
|
|
35
|
+
typer.echo(json.dumps({"error": message}))
|
|
36
|
+
else:
|
|
37
|
+
typer.echo(f"Error: {message}", err=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@auth_app.command("login")
|
|
41
|
+
def auth_login(
|
|
42
|
+
url: str = typer.Option(None, "--url", help="VentilatePro base URL"),
|
|
43
|
+
token: str = typer.Option(None, "--token", help="Personal access token"),
|
|
44
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
45
|
+
):
|
|
46
|
+
try:
|
|
47
|
+
result = auth.login(url, token)
|
|
48
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
49
|
+
emit_error(str(exc), json_output=json_output)
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
emit(
|
|
52
|
+
result,
|
|
53
|
+
json_output=json_output,
|
|
54
|
+
lines=[
|
|
55
|
+
f"Authenticated as {result['user'].get('name') or result['user'].get('username')}.",
|
|
56
|
+
f"Base URL: {result['base_url']}",
|
|
57
|
+
f"Token storage: {result['token_storage']}",
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@auth_app.command("status")
|
|
63
|
+
def auth_status(
|
|
64
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
65
|
+
):
|
|
66
|
+
result = auth.status()
|
|
67
|
+
lines = ["Authenticated." if result["authenticated"] else "Not authenticated."]
|
|
68
|
+
if result.get("base_url"):
|
|
69
|
+
lines.append(f"Base URL: {result['base_url']}")
|
|
70
|
+
emit(result, json_output=json_output, lines=lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@auth_app.command("whoami")
|
|
74
|
+
def auth_whoami(
|
|
75
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
76
|
+
):
|
|
77
|
+
try:
|
|
78
|
+
result = auth.whoami()
|
|
79
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
80
|
+
emit_error(str(exc), json_output=json_output)
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
emit(
|
|
83
|
+
result,
|
|
84
|
+
json_output=json_output,
|
|
85
|
+
lines=[
|
|
86
|
+
f"User: {result.get('name') or result.get('username')}",
|
|
87
|
+
f"Email: {result.get('email')}",
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@auth_app.command("logout")
|
|
93
|
+
def auth_logout(
|
|
94
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
95
|
+
):
|
|
96
|
+
result = auth.logout()
|
|
97
|
+
emit(result, json_output=json_output, lines=["Removed local credentials."])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@projects_app.command("list")
|
|
101
|
+
def projects_list(
|
|
102
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
103
|
+
):
|
|
104
|
+
try:
|
|
105
|
+
client, _ = auth.get_authenticated_client()
|
|
106
|
+
result = client.projects_list()
|
|
107
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
108
|
+
emit_error(str(exc), json_output=json_output)
|
|
109
|
+
raise typer.Exit(1)
|
|
110
|
+
|
|
111
|
+
lines = [f"{project['id']}\t{project['name']}" for project in result]
|
|
112
|
+
if not lines:
|
|
113
|
+
lines = ["No accessible projects found."]
|
|
114
|
+
emit(result, json_output=json_output, lines=lines)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@notes_app.command("create")
|
|
118
|
+
def notes_create(
|
|
119
|
+
project: int = typer.Option(..., "--project", help="Target project ID"),
|
|
120
|
+
title: str = typer.Option(None, "--title", help="Note title"),
|
|
121
|
+
body: str = typer.Option(None, "--body", help="Note body"),
|
|
122
|
+
use_stdin: bool = typer.Option(False, "--stdin", help="Read note body from stdin"),
|
|
123
|
+
tags: str = typer.Option(None, "--tags", help="Comma-separated tags"),
|
|
124
|
+
captured_at: str = typer.Option(None, "--captured-at", help="Capture timestamp in ISO-8601 format"),
|
|
125
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
126
|
+
):
|
|
127
|
+
try:
|
|
128
|
+
client, _ = auth.get_authenticated_client()
|
|
129
|
+
queue_store = QueueStore()
|
|
130
|
+
body_text = notes.resolve_body(body, use_stdin)
|
|
131
|
+
result = notes.create_note(
|
|
132
|
+
client,
|
|
133
|
+
queue_store,
|
|
134
|
+
project=project,
|
|
135
|
+
title=title,
|
|
136
|
+
body=body_text,
|
|
137
|
+
tags=notes.parse_tags(tags),
|
|
138
|
+
captured_at=captured_at,
|
|
139
|
+
)
|
|
140
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
141
|
+
emit_error(str(exc), json_output=json_output)
|
|
142
|
+
raise typer.Exit(1)
|
|
143
|
+
|
|
144
|
+
if result["status"] == "queued":
|
|
145
|
+
emit(
|
|
146
|
+
result,
|
|
147
|
+
json_output=json_output,
|
|
148
|
+
lines=[f"Queued note locally as #{result['local_id']} due to a retryable API failure."],
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
note_ref = (result.get("note") or {}).get("note_ref", "unknown")
|
|
153
|
+
emit(
|
|
154
|
+
result,
|
|
155
|
+
json_output=json_output,
|
|
156
|
+
lines=[f"Created note {note_ref} in project {project}."],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@notes_app.command("sync")
|
|
161
|
+
def notes_sync(
|
|
162
|
+
project: int = typer.Option(None, "--project", help="Only sync queued notes for this project"),
|
|
163
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
164
|
+
):
|
|
165
|
+
try:
|
|
166
|
+
client, _ = auth.get_authenticated_client()
|
|
167
|
+
result = notes.sync_notes(client, QueueStore(), project=project)
|
|
168
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
169
|
+
emit_error(str(exc), json_output=json_output)
|
|
170
|
+
raise typer.Exit(1)
|
|
171
|
+
|
|
172
|
+
emit(
|
|
173
|
+
result,
|
|
174
|
+
json_output=json_output,
|
|
175
|
+
lines=[f"Processed {result['total']} queued notes: {result['synced']} synced, {result['failed']} failed."],
|
|
176
|
+
)
|
|
177
|
+
if result["failed"] > 0:
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@notes_app.command("list")
|
|
182
|
+
def notes_list(
|
|
183
|
+
project: int = typer.Option(..., "--project", help="Target project ID"),
|
|
184
|
+
limit: int = typer.Option(20, "--limit", help="Maximum notes to return"),
|
|
185
|
+
offset: int = typer.Option(0, "--offset", help="Starting offset"),
|
|
186
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
187
|
+
):
|
|
188
|
+
try:
|
|
189
|
+
client, _ = auth.get_authenticated_client()
|
|
190
|
+
result = client.list_notes(project, limit=limit, offset=offset)
|
|
191
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
192
|
+
emit_error(str(exc), json_output=json_output)
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
|
|
195
|
+
lines = [
|
|
196
|
+
f"{item['note_ref']}\t{item['title']}\t{item['type']}\t{item['source']}"
|
|
197
|
+
for item in result.get("results", [])
|
|
198
|
+
]
|
|
199
|
+
if not lines:
|
|
200
|
+
lines = ["No notes found."]
|
|
201
|
+
emit(result, json_output=json_output, lines=lines)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@notes_app.command("show")
|
|
205
|
+
def notes_show(
|
|
206
|
+
note_ref: str = typer.Argument(..., help="Typed note reference, such as general-info:42"),
|
|
207
|
+
project: int = typer.Option(..., "--project", help="Target project ID"),
|
|
208
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
209
|
+
):
|
|
210
|
+
try:
|
|
211
|
+
client, _ = auth.get_authenticated_client()
|
|
212
|
+
result = client.show_note(note_ref, project=project)
|
|
213
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
214
|
+
emit_error(str(exc), json_output=json_output)
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
|
|
217
|
+
emit(
|
|
218
|
+
result,
|
|
219
|
+
json_output=json_output,
|
|
220
|
+
lines=[
|
|
221
|
+
f"{result['note_ref']} [{result['type']}]",
|
|
222
|
+
f"Title: {result['title']}",
|
|
223
|
+
f"Source: {result['source']}",
|
|
224
|
+
f"Tags: {', '.join(result.get('tags') or []) or 'None'}",
|
|
225
|
+
"",
|
|
226
|
+
result["body"],
|
|
227
|
+
],
|
|
228
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
APP_DIR_NAME = "ventilatepro"
|
|
9
|
+
CONFIG_ENV_VAR = "VENTILATEPRO_CONFIG_DIR"
|
|
10
|
+
DATA_ENV_VAR = "VENTILATEPRO_DATA_DIR"
|
|
11
|
+
CONFIG_FILENAME = "config.json"
|
|
12
|
+
KEYRING_SERVICE = "ventilatepro-cli"
|
|
13
|
+
KEYRING_USERNAME = "default"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fallback_config_dir() -> Path:
|
|
17
|
+
return Path.home() / ".config" / APP_DIR_NAME
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fallback_data_dir() -> Path:
|
|
21
|
+
return Path.home() / ".local" / "share" / APP_DIR_NAME
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_config_dir() -> Path:
|
|
25
|
+
override = os.getenv(CONFIG_ENV_VAR)
|
|
26
|
+
if override:
|
|
27
|
+
return Path(override)
|
|
28
|
+
try:
|
|
29
|
+
from platformdirs import user_config_dir
|
|
30
|
+
|
|
31
|
+
return Path(user_config_dir(APP_DIR_NAME, "VentilatePro"))
|
|
32
|
+
except Exception:
|
|
33
|
+
return _fallback_config_dir()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_data_dir() -> Path:
|
|
37
|
+
override = os.getenv(DATA_ENV_VAR)
|
|
38
|
+
if override:
|
|
39
|
+
return Path(override)
|
|
40
|
+
try:
|
|
41
|
+
from platformdirs import user_data_dir
|
|
42
|
+
|
|
43
|
+
return Path(user_data_dir(APP_DIR_NAME, "VentilatePro"))
|
|
44
|
+
except Exception:
|
|
45
|
+
return _fallback_data_dir()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_config_path() -> Path:
|
|
49
|
+
return get_config_dir() / CONFIG_FILENAME
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ensure_dirs() -> None:
|
|
53
|
+
get_config_dir().mkdir(parents=True, exist_ok=True)
|
|
54
|
+
get_data_dir().mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_settings() -> Dict[str, Any]:
|
|
58
|
+
path = get_config_path()
|
|
59
|
+
if not path.exists():
|
|
60
|
+
return {}
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except json.JSONDecodeError:
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_settings(settings: Dict[str, Any]) -> None:
|
|
68
|
+
ensure_dirs()
|
|
69
|
+
get_config_path().write_text(json.dumps(settings, indent=2, sort_keys=True), encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def normalize_base_url(base_url: str) -> str:
|
|
73
|
+
return (base_url or "").strip().rstrip("/")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def set_base_url(base_url: str) -> None:
|
|
77
|
+
settings = load_settings()
|
|
78
|
+
settings["base_url"] = normalize_base_url(base_url)
|
|
79
|
+
save_settings(settings)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_base_url() -> Optional[str]:
|
|
83
|
+
return normalize_base_url(load_settings().get("base_url", "")) or None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _try_get_keyring():
|
|
87
|
+
try:
|
|
88
|
+
import keyring
|
|
89
|
+
|
|
90
|
+
return keyring
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def save_token(token: str) -> str:
|
|
96
|
+
settings = load_settings()
|
|
97
|
+
keyring = _try_get_keyring()
|
|
98
|
+
if keyring is not None:
|
|
99
|
+
try:
|
|
100
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, token)
|
|
101
|
+
settings["token_storage"] = "keyring"
|
|
102
|
+
settings.pop("token", None)
|
|
103
|
+
save_settings(settings)
|
|
104
|
+
return "keyring"
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
settings["token_storage"] = "file"
|
|
109
|
+
settings["token"] = token
|
|
110
|
+
save_settings(settings)
|
|
111
|
+
return "file"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_token() -> Optional[str]:
|
|
115
|
+
settings = load_settings()
|
|
116
|
+
storage = settings.get("token_storage")
|
|
117
|
+
if storage == "keyring":
|
|
118
|
+
keyring = _try_get_keyring()
|
|
119
|
+
if keyring is not None:
|
|
120
|
+
try:
|
|
121
|
+
token = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
|
122
|
+
if token:
|
|
123
|
+
return token
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return settings.get("token") or None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def delete_token() -> None:
|
|
130
|
+
settings = load_settings()
|
|
131
|
+
keyring = _try_get_keyring()
|
|
132
|
+
if keyring is not None:
|
|
133
|
+
try:
|
|
134
|
+
keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
settings.pop("token", None)
|
|
138
|
+
settings.pop("token_storage", None)
|
|
139
|
+
save_settings(settings)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def clear_auth() -> None:
|
|
143
|
+
settings = load_settings()
|
|
144
|
+
settings.pop("base_url", None)
|
|
145
|
+
settings.pop("token", None)
|
|
146
|
+
settings.pop("token_storage", None)
|
|
147
|
+
delete_token()
|
|
148
|
+
save_settings(settings)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .api_client import ApiError, NetworkError
|
|
8
|
+
from .queue import QueueStore
|
|
9
|
+
|
|
10
|
+
TITLE_MAX_LENGTH = 80
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def derive_title(body: str, fallback: str = "Untitled CLI Note") -> str:
|
|
14
|
+
for raw_line in (body or "").splitlines():
|
|
15
|
+
line = raw_line.strip()
|
|
16
|
+
if not line:
|
|
17
|
+
continue
|
|
18
|
+
if line.startswith("# "):
|
|
19
|
+
line = line[2:].strip()
|
|
20
|
+
return line[:TITLE_MAX_LENGTH] or fallback
|
|
21
|
+
return fallback
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_tags(tags_csv: Optional[str]) -> list[str]:
|
|
25
|
+
if not tags_csv:
|
|
26
|
+
return []
|
|
27
|
+
seen = set()
|
|
28
|
+
tags = []
|
|
29
|
+
for raw_tag in tags_csv.split(","):
|
|
30
|
+
tag = raw_tag.strip()
|
|
31
|
+
if not tag or tag in seen:
|
|
32
|
+
continue
|
|
33
|
+
seen.add(tag)
|
|
34
|
+
tags.append(tag)
|
|
35
|
+
return tags
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_body(body: Optional[str], use_stdin: bool) -> str:
|
|
39
|
+
if body and use_stdin:
|
|
40
|
+
raise ValueError("Use either --body or --stdin, not both.")
|
|
41
|
+
if use_stdin:
|
|
42
|
+
stdin_body = sys.stdin.read()
|
|
43
|
+
if not stdin_body.strip():
|
|
44
|
+
raise ValueError("No note body received on stdin.")
|
|
45
|
+
return stdin_body
|
|
46
|
+
if not body or not body.strip():
|
|
47
|
+
raise ValueError("Provide --body or --stdin.")
|
|
48
|
+
return body
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_note_payload(project: int, title: Optional[str], body: str, tags: list[str], captured_at: Optional[str]):
|
|
52
|
+
resolved_title = (title or "").strip() or derive_title(body)
|
|
53
|
+
return {
|
|
54
|
+
"project": project,
|
|
55
|
+
"title": resolved_title,
|
|
56
|
+
"body": body,
|
|
57
|
+
"tags": tags,
|
|
58
|
+
"captured_at": captured_at,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_note(client, queue_store: QueueStore, *, project: int, title: Optional[str], body: str, tags: list[str], captured_at: Optional[str]):
|
|
63
|
+
payload = build_note_payload(project, title, body, tags, captured_at)
|
|
64
|
+
idempotency_key = str(uuid.uuid4())
|
|
65
|
+
try:
|
|
66
|
+
response = client.create_note(payload, idempotency_key=idempotency_key)
|
|
67
|
+
return {
|
|
68
|
+
"status": "created",
|
|
69
|
+
"idempotency_key": idempotency_key,
|
|
70
|
+
"project": project,
|
|
71
|
+
"note": response.get("note"),
|
|
72
|
+
"audit_entry_id": response.get("audit_entry_id"),
|
|
73
|
+
}
|
|
74
|
+
except NetworkError as exc:
|
|
75
|
+
queued_note = queue_store.enqueue(
|
|
76
|
+
project_id=project,
|
|
77
|
+
title=payload["title"],
|
|
78
|
+
body=payload["body"],
|
|
79
|
+
tags=payload["tags"],
|
|
80
|
+
captured_at=captured_at,
|
|
81
|
+
idempotency_key=idempotency_key,
|
|
82
|
+
)
|
|
83
|
+
return {
|
|
84
|
+
"status": "queued",
|
|
85
|
+
"project": project,
|
|
86
|
+
"local_id": queued_note.local_id,
|
|
87
|
+
"error": str(exc),
|
|
88
|
+
"idempotency_key": idempotency_key,
|
|
89
|
+
}
|
|
90
|
+
except ApiError as exc:
|
|
91
|
+
if exc.status_code >= 500:
|
|
92
|
+
queued_note = queue_store.enqueue(
|
|
93
|
+
project_id=project,
|
|
94
|
+
title=payload["title"],
|
|
95
|
+
body=payload["body"],
|
|
96
|
+
tags=payload["tags"],
|
|
97
|
+
captured_at=captured_at,
|
|
98
|
+
idempotency_key=idempotency_key,
|
|
99
|
+
)
|
|
100
|
+
return {
|
|
101
|
+
"status": "queued",
|
|
102
|
+
"project": project,
|
|
103
|
+
"local_id": queued_note.local_id,
|
|
104
|
+
"error": str(exc),
|
|
105
|
+
"idempotency_key": idempotency_key,
|
|
106
|
+
}
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def sync_notes(client, queue_store: QueueStore, *, project: Optional[int] = None):
|
|
111
|
+
queued_notes = queue_store.list_unsynced(project)
|
|
112
|
+
results = []
|
|
113
|
+
synced_count = 0
|
|
114
|
+
failed_count = 0
|
|
115
|
+
for queued_note in queued_notes:
|
|
116
|
+
queue_store.mark_syncing(queued_note.local_id)
|
|
117
|
+
try:
|
|
118
|
+
response = client.create_note(
|
|
119
|
+
build_note_payload(
|
|
120
|
+
queued_note.project_id,
|
|
121
|
+
queued_note.title,
|
|
122
|
+
queued_note.body,
|
|
123
|
+
queued_note.tags,
|
|
124
|
+
queued_note.captured_at,
|
|
125
|
+
),
|
|
126
|
+
idempotency_key=queued_note.idempotency_key,
|
|
127
|
+
)
|
|
128
|
+
note_ref = response.get("note", {}).get("note_ref")
|
|
129
|
+
queue_store.mark_synced(queued_note.local_id, note_ref or "")
|
|
130
|
+
synced_count += 1
|
|
131
|
+
results.append({
|
|
132
|
+
"local_id": queued_note.local_id,
|
|
133
|
+
"status": "synced",
|
|
134
|
+
"note_ref": note_ref,
|
|
135
|
+
"project": queued_note.project_id,
|
|
136
|
+
})
|
|
137
|
+
except (NetworkError, ApiError) as exc:
|
|
138
|
+
queue_store.mark_failed(queued_note.local_id, str(exc))
|
|
139
|
+
failed_count += 1
|
|
140
|
+
results.append({
|
|
141
|
+
"local_id": queued_note.local_id,
|
|
142
|
+
"status": "failed",
|
|
143
|
+
"project": queued_note.project_id,
|
|
144
|
+
"error": str(exc),
|
|
145
|
+
})
|
|
146
|
+
return {
|
|
147
|
+
"total": len(queued_notes),
|
|
148
|
+
"synced": synced_count,
|
|
149
|
+
"failed": failed_count,
|
|
150
|
+
"results": results,
|
|
151
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from .config import get_data_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class QueuedNote:
|
|
15
|
+
local_id: int
|
|
16
|
+
project_id: int
|
|
17
|
+
title: str
|
|
18
|
+
body: str
|
|
19
|
+
tags: list[str]
|
|
20
|
+
captured_at: Optional[str]
|
|
21
|
+
created_at: str
|
|
22
|
+
idempotency_key: str
|
|
23
|
+
sync_state: str
|
|
24
|
+
last_sync_attempt_at: Optional[str]
|
|
25
|
+
last_error: Optional[str]
|
|
26
|
+
server_note_ref: Optional[str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class QueueStore:
|
|
30
|
+
def __init__(self, db_path: Optional[Path] = None):
|
|
31
|
+
self.db_path = db_path or (get_data_dir() / "queue.sqlite3")
|
|
32
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
self._initialize()
|
|
34
|
+
|
|
35
|
+
def _connect(self):
|
|
36
|
+
return sqlite3.connect(self.db_path)
|
|
37
|
+
|
|
38
|
+
def _initialize(self) -> None:
|
|
39
|
+
with self._connect() as connection:
|
|
40
|
+
connection.execute(
|
|
41
|
+
"""
|
|
42
|
+
CREATE TABLE IF NOT EXISTS queued_notes (
|
|
43
|
+
local_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
project_id INTEGER NOT NULL,
|
|
45
|
+
title TEXT NOT NULL,
|
|
46
|
+
body TEXT NOT NULL,
|
|
47
|
+
tags TEXT NOT NULL,
|
|
48
|
+
captured_at TEXT NULL,
|
|
49
|
+
created_at TEXT NOT NULL,
|
|
50
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
51
|
+
sync_state TEXT NOT NULL,
|
|
52
|
+
last_sync_attempt_at TEXT NULL,
|
|
53
|
+
last_error TEXT NULL,
|
|
54
|
+
server_note_ref TEXT NULL
|
|
55
|
+
)
|
|
56
|
+
"""
|
|
57
|
+
)
|
|
58
|
+
connection.execute(
|
|
59
|
+
"CREATE INDEX IF NOT EXISTS queued_notes_sync_state_idx ON queued_notes(sync_state)"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def enqueue(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
project_id: int,
|
|
66
|
+
title: str,
|
|
67
|
+
body: str,
|
|
68
|
+
tags: list[str],
|
|
69
|
+
captured_at: Optional[str],
|
|
70
|
+
idempotency_key: str,
|
|
71
|
+
) -> QueuedNote:
|
|
72
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
73
|
+
with self._connect() as connection:
|
|
74
|
+
cursor = connection.execute(
|
|
75
|
+
"""
|
|
76
|
+
INSERT INTO queued_notes (
|
|
77
|
+
project_id, title, body, tags, captured_at, created_at, idempotency_key, sync_state
|
|
78
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
79
|
+
""",
|
|
80
|
+
(
|
|
81
|
+
project_id,
|
|
82
|
+
title,
|
|
83
|
+
body,
|
|
84
|
+
json.dumps(tags),
|
|
85
|
+
captured_at,
|
|
86
|
+
created_at,
|
|
87
|
+
idempotency_key,
|
|
88
|
+
"pending",
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
local_id = cursor.lastrowid
|
|
92
|
+
return self.get(local_id)
|
|
93
|
+
|
|
94
|
+
def get(self, local_id: int) -> QueuedNote:
|
|
95
|
+
with self._connect() as connection:
|
|
96
|
+
row = connection.execute(
|
|
97
|
+
"""
|
|
98
|
+
SELECT local_id, project_id, title, body, tags, captured_at, created_at,
|
|
99
|
+
idempotency_key, sync_state, last_sync_attempt_at, last_error, server_note_ref
|
|
100
|
+
FROM queued_notes
|
|
101
|
+
WHERE local_id = ?
|
|
102
|
+
""",
|
|
103
|
+
(local_id,),
|
|
104
|
+
).fetchone()
|
|
105
|
+
if row is None:
|
|
106
|
+
raise KeyError(f"Queued note {local_id} not found.")
|
|
107
|
+
return self._row_to_note(row)
|
|
108
|
+
|
|
109
|
+
def list_unsynced(self, project_id: Optional[int] = None) -> List[QueuedNote]:
|
|
110
|
+
query = """
|
|
111
|
+
SELECT local_id, project_id, title, body, tags, captured_at, created_at,
|
|
112
|
+
idempotency_key, sync_state, last_sync_attempt_at, last_error, server_note_ref
|
|
113
|
+
FROM queued_notes
|
|
114
|
+
WHERE sync_state IN ('pending', 'failed')
|
|
115
|
+
"""
|
|
116
|
+
params: list[object] = []
|
|
117
|
+
if project_id is not None:
|
|
118
|
+
query += " AND project_id = ?"
|
|
119
|
+
params.append(project_id)
|
|
120
|
+
query += " ORDER BY local_id ASC"
|
|
121
|
+
with self._connect() as connection:
|
|
122
|
+
rows = connection.execute(query, params).fetchall()
|
|
123
|
+
return [self._row_to_note(row) for row in rows]
|
|
124
|
+
|
|
125
|
+
def mark_syncing(self, local_id: int) -> None:
|
|
126
|
+
with self._connect() as connection:
|
|
127
|
+
connection.execute(
|
|
128
|
+
"""
|
|
129
|
+
UPDATE queued_notes
|
|
130
|
+
SET sync_state = ?, last_sync_attempt_at = ?, last_error = NULL
|
|
131
|
+
WHERE local_id = ?
|
|
132
|
+
""",
|
|
133
|
+
("syncing", datetime.now(timezone.utc).isoformat(), local_id),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def mark_synced(self, local_id: int, note_ref: str) -> None:
|
|
137
|
+
with self._connect() as connection:
|
|
138
|
+
connection.execute(
|
|
139
|
+
"""
|
|
140
|
+
UPDATE queued_notes
|
|
141
|
+
SET sync_state = ?, last_sync_attempt_at = ?, last_error = NULL, server_note_ref = ?
|
|
142
|
+
WHERE local_id = ?
|
|
143
|
+
""",
|
|
144
|
+
("synced", datetime.now(timezone.utc).isoformat(), note_ref, local_id),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def mark_failed(self, local_id: int, error_message: str) -> None:
|
|
148
|
+
with self._connect() as connection:
|
|
149
|
+
connection.execute(
|
|
150
|
+
"""
|
|
151
|
+
UPDATE queued_notes
|
|
152
|
+
SET sync_state = ?, last_sync_attempt_at = ?, last_error = ?
|
|
153
|
+
WHERE local_id = ?
|
|
154
|
+
""",
|
|
155
|
+
("failed", datetime.now(timezone.utc).isoformat(), error_message, local_id),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def count(self) -> int:
|
|
159
|
+
with self._connect() as connection:
|
|
160
|
+
row = connection.execute("SELECT COUNT(*) FROM queued_notes").fetchone()
|
|
161
|
+
return int(row[0] if row else 0)
|
|
162
|
+
|
|
163
|
+
def _row_to_note(self, row) -> QueuedNote:
|
|
164
|
+
return QueuedNote(
|
|
165
|
+
local_id=row[0],
|
|
166
|
+
project_id=row[1],
|
|
167
|
+
title=row[2],
|
|
168
|
+
body=row[3],
|
|
169
|
+
tags=json.loads(row[4] or "[]"),
|
|
170
|
+
captured_at=row[5],
|
|
171
|
+
created_at=row[6],
|
|
172
|
+
idempotency_key=row[7],
|
|
173
|
+
sync_state=row[8],
|
|
174
|
+
last_sync_attempt_at=row[9],
|
|
175
|
+
last_error=row[10],
|
|
176
|
+
server_note_ref=row[11],
|
|
177
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ventilatepro-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: VentilatePro CLI
|
|
5
|
+
Project-URL: Homepage, https://ventilatepro.com/exam/cli/
|
|
6
|
+
Project-URL: Documentation, https://ventilatepro.com/exam/cli/
|
|
7
|
+
Project-URL: Support, https://ventilatepro.com/exam/support/
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: typer>=0.16.0
|
|
11
|
+
Requires-Dist: requests>=2.32.0
|
|
12
|
+
Requires-Dist: keyring>=25.6.0
|
|
13
|
+
Requires-Dist: platformdirs>=4.3.0
|
|
14
|
+
|
|
15
|
+
# VentilatePro CLI
|
|
16
|
+
|
|
17
|
+
VentilatePro CLI is a local command-line client for authenticating to VentilatePro and creating or syncing notes through the scoped CLI API.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Published releases install from PyPI:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pipx install ventilatepro-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Upgrade an existing install:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pipx upgrade ventilatepro-cli
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Local development install:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd ventilatepro-cli
|
|
37
|
+
pip install -e .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
ventilatepro auth login --url http://localhost:8000
|
|
44
|
+
ventilatepro projects list
|
|
45
|
+
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
46
|
+
ventilatepro notes sync
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
See the hosted install guide at `https://ventilatepro.com/exam/cli/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_cli.py
|
|
4
|
+
tests/test_config.py
|
|
5
|
+
ventilatepro/__init__.py
|
|
6
|
+
ventilatepro/__main__.py
|
|
7
|
+
ventilatepro/api_client.py
|
|
8
|
+
ventilatepro/auth.py
|
|
9
|
+
ventilatepro/cli.py
|
|
10
|
+
ventilatepro/config.py
|
|
11
|
+
ventilatepro/notes.py
|
|
12
|
+
ventilatepro/queue.py
|
|
13
|
+
ventilatepro_cli.egg-info/PKG-INFO
|
|
14
|
+
ventilatepro_cli.egg-info/SOURCES.txt
|
|
15
|
+
ventilatepro_cli.egg-info/dependency_links.txt
|
|
16
|
+
ventilatepro_cli.egg-info/entry_points.txt
|
|
17
|
+
ventilatepro_cli.egg-info/requires.txt
|
|
18
|
+
ventilatepro_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ventilatepro
|