ventilatepro-cli 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/PKG-INFO +20 -1
  2. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/README.md +19 -0
  3. ventilatepro_cli-0.2.0/tests/test_cli.py +404 -0
  4. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro/__init__.py +1 -1
  5. ventilatepro_cli-0.2.0/ventilatepro/api_client.py +221 -0
  6. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro/auth.py +31 -2
  7. ventilatepro_cli-0.2.0/ventilatepro/calc.py +142 -0
  8. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro/cli.py +39 -38
  9. ventilatepro_cli-0.2.0/ventilatepro/design_day.py +95 -0
  10. ventilatepro_cli-0.2.0/ventilatepro/entities.py +210 -0
  11. ventilatepro_cli-0.2.0/ventilatepro/output.py +31 -0
  12. ventilatepro_cli-0.2.0/ventilatepro/projects.py +55 -0
  13. ventilatepro_cli-0.2.0/ventilatepro/systems.py +76 -0
  14. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/PKG-INFO +20 -1
  15. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/SOURCES.txt +6 -0
  16. ventilatepro_cli-0.1.0/tests/test_cli.py +0 -171
  17. ventilatepro_cli-0.1.0/ventilatepro/api_client.py +0 -90
  18. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/pyproject.toml +0 -0
  19. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/setup.cfg +0 -0
  20. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/tests/test_config.py +0 -0
  21. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro/__main__.py +0 -0
  22. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro/config.py +0 -0
  23. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro/notes.py +0 -0
  24. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro/queue.py +0 -0
  25. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
  26. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
  27. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/requires.txt +0 -0
  28. {ventilatepro_cli-0.1.0 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ventilatepro-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: VentilatePro CLI
5
5
  Project-URL: Homepage, https://ventilatepro.com/exam/cli/
6
6
  Project-URL: Documentation, https://ventilatepro.com/exam/cli/
@@ -42,8 +42,27 @@ pip install -e .
42
42
  ```bash
43
43
  ventilatepro auth login --url http://localhost:8000
44
44
  ventilatepro projects list
45
+ ventilatepro projects show 1
46
+ ventilatepro rooms list --project 1
47
+ ventilatepro ahus list --project 1
48
+ ventilatepro systems hierarchy --project 1
49
+ ventilatepro calc status --project 1
50
+ ventilatepro calc ahu-properties --ahu 1
51
+ ventilatepro design-day context --project 1
45
52
  ventilatepro notes create --project 1 --body "Captured from terminal"
46
53
  ventilatepro notes sync
47
54
  ```
48
55
 
56
+ If the hidden token prompt is awkward in your terminal, use either:
57
+
58
+ ```bash
59
+ ventilatepro auth login --url https://ventilatepro.com --token-prompt-visible
60
+ ```
61
+
62
+ or pipe the token through stdin:
63
+
64
+ ```bash
65
+ printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --url https://ventilatepro.com --token-stdin
66
+ ```
67
+
49
68
  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.
@@ -28,8 +28,27 @@ pip install -e .
28
28
  ```bash
29
29
  ventilatepro auth login --url http://localhost:8000
30
30
  ventilatepro projects list
31
+ ventilatepro projects show 1
32
+ ventilatepro rooms list --project 1
33
+ ventilatepro ahus list --project 1
34
+ ventilatepro systems hierarchy --project 1
35
+ ventilatepro calc status --project 1
36
+ ventilatepro calc ahu-properties --ahu 1
37
+ ventilatepro design-day context --project 1
31
38
  ventilatepro notes create --project 1 --body "Captured from terminal"
32
39
  ventilatepro notes sync
33
40
  ```
34
41
 
42
+ If the hidden token prompt is awkward in your terminal, use either:
43
+
44
+ ```bash
45
+ ventilatepro auth login --url https://ventilatepro.com --token-prompt-visible
46
+ ```
47
+
48
+ or pipe the token through stdin:
49
+
50
+ ```bash
51
+ printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --url https://ventilatepro.com --token-stdin
52
+ ```
53
+
35
54
  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,404 @@
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_auth_login_reads_token_from_stdin(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
+ fake_client = mock.Mock()
25
+ fake_client.auth_me.return_value = {
26
+ "username": "demo-user",
27
+ "email": "demo@example.com",
28
+ }
29
+
30
+ with mock.patch.object(auth, "ApiClient", return_value=fake_client):
31
+ with mock.patch.object(config, "_try_get_keyring", return_value=None):
32
+ result = self.runner.invoke(
33
+ app,
34
+ ["auth", "login", "--url", "http://localhost:8000", "--token-stdin", "--json"],
35
+ input="vpcli_demo_secret\n",
36
+ )
37
+
38
+ self.assertEqual(result.exit_code, 0)
39
+ payload = json.loads(result.stdout)
40
+ self.assertEqual(payload["status"], "authenticated")
41
+ self.assertEqual(config.get_token(), "vpcli_demo_secret")
42
+ self.assertEqual(config.get_base_url(), "http://localhost:8000")
43
+ finally:
44
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
45
+ os.environ.pop(config.DATA_ENV_VAR, None)
46
+
47
+ def test_auth_login_accepts_visible_prompt_input(self):
48
+ with tempfile.TemporaryDirectory() as tmpdir:
49
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
50
+ os.environ[config.DATA_ENV_VAR] = tmpdir
51
+ try:
52
+ fake_client = mock.Mock()
53
+ fake_client.auth_me.return_value = {
54
+ "username": "demo-user",
55
+ "email": "demo@example.com",
56
+ }
57
+
58
+ with mock.patch.object(auth, "ApiClient", return_value=fake_client):
59
+ with mock.patch.object(config, "_try_get_keyring", return_value=None):
60
+ result = self.runner.invoke(
61
+ app,
62
+ ["auth", "login", "--url", "http://localhost:8000", "--token-prompt-visible", "--json"],
63
+ input="vpcli_demo_secret\n",
64
+ )
65
+
66
+ self.assertEqual(result.exit_code, 0)
67
+ self.assertIn("Personal access token (visible):", result.stdout)
68
+ self.assertEqual(config.get_token(), "vpcli_demo_secret")
69
+ self.assertEqual(config.get_base_url(), "http://localhost:8000")
70
+ finally:
71
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
72
+ os.environ.pop(config.DATA_ENV_VAR, None)
73
+
74
+ def test_auth_login_rejects_conflicting_token_inputs(self):
75
+ result = self.runner.invoke(
76
+ app,
77
+ [
78
+ "auth",
79
+ "login",
80
+ "--url",
81
+ "http://localhost:8000",
82
+ "--token",
83
+ "vpcli_demo_secret",
84
+ "--token-stdin",
85
+ ],
86
+ )
87
+
88
+ self.assertNotEqual(result.exit_code, 0)
89
+ self.assertIn("either --token or --token-stdin", result.output)
90
+
91
+ def test_status_json_reports_not_authenticated(self):
92
+ with tempfile.TemporaryDirectory() as tmpdir:
93
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
94
+ os.environ[config.DATA_ENV_VAR] = tmpdir
95
+ try:
96
+ result = self.runner.invoke(app, ["auth", "status", "--json"])
97
+ self.assertEqual(result.exit_code, 0)
98
+ payload = json.loads(result.stdout)
99
+ self.assertFalse(payload["authenticated"])
100
+ finally:
101
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
102
+ os.environ.pop(config.DATA_ENV_VAR, None)
103
+
104
+ def test_notes_create_queues_on_network_error(self):
105
+ with tempfile.TemporaryDirectory() as tmpdir:
106
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
107
+ os.environ[config.DATA_ENV_VAR] = tmpdir
108
+ try:
109
+ config.set_base_url("http://localhost:8000")
110
+ with mock.patch.object(config, "_try_get_keyring", return_value=None):
111
+ config.save_token("vpcli_demo_secret")
112
+
113
+ fake_client = mock.Mock()
114
+ fake_client.create_note.side_effect = NetworkError("connection dropped")
115
+
116
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
117
+ result = self.runner.invoke(
118
+ app,
119
+ ["notes", "create", "--project", "1", "--body", "Captured from CLI"],
120
+ )
121
+
122
+ self.assertEqual(result.exit_code, 0)
123
+ queue_store = QueueStore()
124
+ queued = queue_store.list_unsynced()
125
+ self.assertEqual(len(queued), 1)
126
+ self.assertEqual(queued[0].sync_state, "pending")
127
+ finally:
128
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
129
+ os.environ.pop(config.DATA_ENV_VAR, None)
130
+
131
+ def test_notes_create_does_not_queue_validation_errors(self):
132
+ with tempfile.TemporaryDirectory() as tmpdir:
133
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
134
+ os.environ[config.DATA_ENV_VAR] = tmpdir
135
+ try:
136
+ config.set_base_url("http://localhost:8000")
137
+ with mock.patch.object(config, "_try_get_keyring", return_value=None):
138
+ config.save_token("vpcli_demo_secret")
139
+
140
+ fake_client = mock.Mock()
141
+ fake_client.create_note.side_effect = ApiError(400, "Validation failed")
142
+
143
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
144
+ result = self.runner.invoke(
145
+ app,
146
+ ["notes", "create", "--project", "1", "--body", "Captured from CLI"],
147
+ )
148
+
149
+ self.assertNotEqual(result.exit_code, 0)
150
+ queue_store = QueueStore()
151
+ self.assertEqual(queue_store.count(), 0)
152
+ finally:
153
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
154
+ os.environ.pop(config.DATA_ENV_VAR, None)
155
+
156
+ def test_notes_create_derives_title_from_first_non_empty_body_line(self):
157
+ fake_client = mock.Mock()
158
+ fake_client.create_note.return_value = {
159
+ "note": {"note_ref": "general-info:1"},
160
+ "audit_entry_id": 12,
161
+ }
162
+
163
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
164
+ result = self.runner.invoke(
165
+ app,
166
+ ["notes", "create", "--project", "1", "--body", "\n\n First captured line \nSecond line"],
167
+ )
168
+
169
+ self.assertEqual(result.exit_code, 0)
170
+ payload = fake_client.create_note.call_args.args[0]
171
+ self.assertEqual(payload["title"], "First captured line")
172
+ self.assertEqual(payload["body"], "\n\n First captured line \nSecond line")
173
+
174
+ def test_notes_sync_retries_queued_notes_and_marks_them_synced(self):
175
+ with tempfile.TemporaryDirectory() as tmpdir:
176
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
177
+ os.environ[config.DATA_ENV_VAR] = tmpdir
178
+ try:
179
+ queue_store = QueueStore()
180
+ queued = queue_store.enqueue(
181
+ project_id=1,
182
+ title="Queued note",
183
+ body="Queued body",
184
+ tags=["cli"],
185
+ captured_at="2026-03-10T10:00:00-07:00",
186
+ idempotency_key="queued-note-1",
187
+ )
188
+
189
+ fake_client = mock.Mock()
190
+ fake_client.create_note.return_value = {
191
+ "note": {"note_ref": "general-info:99"},
192
+ "audit_entry_id": 25,
193
+ }
194
+
195
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
196
+ result = self.runner.invoke(app, ["notes", "sync", "--project", "1", "--json"])
197
+
198
+ self.assertEqual(result.exit_code, 0)
199
+ payload = json.loads(result.stdout)
200
+ self.assertEqual(payload["total"], 1)
201
+ self.assertEqual(payload["synced"], 1)
202
+ self.assertEqual(payload["failed"], 0)
203
+ self.assertEqual(payload["results"][0]["note_ref"], "general-info:99")
204
+
205
+ synced = queue_store.get(queued.local_id)
206
+ self.assertEqual(synced.sync_state, "synced")
207
+ self.assertEqual(synced.server_note_ref, "general-info:99")
208
+ fake_client.create_note.assert_called_once_with(
209
+ {
210
+ "project": 1,
211
+ "title": "Queued note",
212
+ "body": "Queued body",
213
+ "tags": ["cli"],
214
+ "captured_at": "2026-03-10T10:00:00-07:00",
215
+ },
216
+ idempotency_key="queued-note-1",
217
+ )
218
+ finally:
219
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
220
+ os.environ.pop(config.DATA_ENV_VAR, None)
221
+
222
+ def test_notes_list_json_outputs_server_payload(self):
223
+ fake_client = mock.Mock()
224
+ fake_client.list_notes.return_value = {
225
+ "project_id": 1,
226
+ "count": 1,
227
+ "limit": 20,
228
+ "offset": 0,
229
+ "results": [
230
+ {
231
+ "note_ref": "general-info:1",
232
+ "title": "CLI Note",
233
+ "type": "general-info",
234
+ "source": "cli",
235
+ }
236
+ ],
237
+ }
238
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
239
+ result = self.runner.invoke(app, ["notes", "list", "--project", "1", "--json"])
240
+
241
+ self.assertEqual(result.exit_code, 0)
242
+ payload = json.loads(result.stdout)
243
+ self.assertEqual(payload["results"][0]["note_ref"], "general-info:1")
244
+
245
+ def test_notes_list_passes_filters_to_api_client(self):
246
+ fake_client = mock.Mock()
247
+ fake_client.list_notes.return_value = {
248
+ "project_id": 1,
249
+ "count": 0,
250
+ "limit": 5,
251
+ "offset": 10,
252
+ "results": [],
253
+ }
254
+
255
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
256
+ result = self.runner.invoke(
257
+ app,
258
+ [
259
+ "notes",
260
+ "list",
261
+ "--project",
262
+ "1",
263
+ "--limit",
264
+ "5",
265
+ "--offset",
266
+ "10",
267
+ "--type",
268
+ "general-info",
269
+ "--source",
270
+ "cli",
271
+ "--tags",
272
+ "field,urgent",
273
+ "--query",
274
+ "terminal",
275
+ "--json",
276
+ ],
277
+ )
278
+
279
+ self.assertEqual(result.exit_code, 0)
280
+ fake_client.list_notes.assert_called_once_with(
281
+ 1,
282
+ limit=5,
283
+ offset=10,
284
+ note_type="general-info",
285
+ source="cli",
286
+ tags=["field", "urgent"],
287
+ query="terminal",
288
+ )
289
+
290
+ def test_projects_list_json_outputs_paginated_payload(self):
291
+ fake_client = mock.Mock()
292
+ fake_client.projects_list.return_value = {
293
+ "count": 1,
294
+ "limit": 20,
295
+ "offset": 0,
296
+ "results": [
297
+ {
298
+ "id": 1,
299
+ "name": "CLI Project",
300
+ }
301
+ ],
302
+ }
303
+
304
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
305
+ result = self.runner.invoke(app, ["projects", "list", "--json"])
306
+
307
+ self.assertEqual(result.exit_code, 0)
308
+ payload = json.loads(result.stdout)
309
+ self.assertEqual(payload["results"][0]["name"], "CLI Project")
310
+ fake_client.projects_list.assert_called_once_with(limit=20, offset=0, query=None)
311
+
312
+ def test_projects_show_calls_api_client(self):
313
+ fake_client = mock.Mock()
314
+ fake_client.project_show.return_value = {
315
+ "id": 7,
316
+ "name": "Hospital South Wing",
317
+ "counts": {"rooms": 3, "zones": 2, "ahus": 1},
318
+ "location": "Los Angeles",
319
+ "time_zone": "America/Los_Angeles",
320
+ }
321
+
322
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
323
+ result = self.runner.invoke(app, ["projects", "show", "7"])
324
+
325
+ self.assertEqual(result.exit_code, 0)
326
+ self.assertIn("Hospital South Wing", result.stdout)
327
+ fake_client.project_show.assert_called_once_with(7)
328
+
329
+ def test_rooms_list_passes_filters_to_api_client(self):
330
+ fake_client = mock.Mock()
331
+ fake_client.list_rooms.return_value = {
332
+ "project_id": 1,
333
+ "count": 0,
334
+ "limit": 5,
335
+ "offset": 10,
336
+ "results": [],
337
+ }
338
+
339
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
340
+ result = self.runner.invoke(
341
+ app,
342
+ [
343
+ "rooms",
344
+ "list",
345
+ "--project",
346
+ "1",
347
+ "--limit",
348
+ "5",
349
+ "--offset",
350
+ "10",
351
+ "--query",
352
+ "operating",
353
+ "--json",
354
+ ],
355
+ )
356
+
357
+ self.assertEqual(result.exit_code, 0)
358
+ fake_client.list_rooms.assert_called_once_with(1, limit=5, offset=10, query="operating")
359
+
360
+ def test_calc_recalculate_stale_passes_mode_scope_and_force(self):
361
+ fake_client = mock.Mock()
362
+ fake_client.calc_recalculate_stale.return_value = {
363
+ "message": "Queued stale recalculation.",
364
+ }
365
+
366
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
367
+ result = self.runner.invoke(
368
+ app,
369
+ [
370
+ "calc",
371
+ "recalculate-stale",
372
+ "--project",
373
+ "1",
374
+ "--scope",
375
+ "zones",
376
+ "--mode",
377
+ "sync",
378
+ "--force",
379
+ "--json",
380
+ ],
381
+ )
382
+
383
+ self.assertEqual(result.exit_code, 0)
384
+ fake_client.calc_recalculate_stale.assert_called_once_with(1, scope="zones", mode="sync", force=True)
385
+
386
+ def test_design_day_run_reads_json_payload_from_file(self):
387
+ fake_client = mock.Mock()
388
+ fake_client.design_day_run.return_value = {
389
+ "id": 42,
390
+ "state": "QUEUED",
391
+ }
392
+
393
+ with tempfile.NamedTemporaryFile("w+", encoding="utf-8") as handle:
394
+ json.dump({"zones": [{"zone_id": 1}]}, handle)
395
+ handle.flush()
396
+
397
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
398
+ result = self.runner.invoke(
399
+ app,
400
+ ["design-day", "run", "--project", "1", "--input", handle.name, "--json"],
401
+ )
402
+
403
+ self.assertEqual(result.exit_code, 0)
404
+ fake_client.design_day_run.assert_called_once_with(1, {"zones": [{"zone_id": 1}]})
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"