devnomads-cli 0.11.1__tar.gz → 0.11.2__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 (22) hide show
  1. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/PKG-INFO +10 -6
  2. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/README.md +9 -5
  3. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/devnomads_cli.egg-info/PKG-INFO +10 -6
  4. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/dncli.py +150 -14
  5. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/pyproject.toml +1 -1
  6. devnomads_cli-0.11.2/tests/test_versions.py +271 -0
  7. devnomads_cli-0.11.1/tests/test_versions.py +0 -157
  8. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/LICENSE +0 -0
  9. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  10. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  11. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/devnomads_cli.egg-info/entry_points.txt +0 -0
  12. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/devnomads_cli.egg-info/requires.txt +0 -0
  13. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/devnomads_cli.egg-info/top_level.txt +0 -0
  14. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/setup.cfg +0 -0
  15. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/tests/test_cert.py +0 -0
  16. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/tests/test_cli.py +0 -0
  17. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/tests/test_config.py +0 -0
  18. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/tests/test_generate.py +0 -0
  19. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/tests/test_generated_cli.py +0 -0
  20. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/tests/test_helpers.py +0 -0
  21. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/tests/test_hook.py +0 -0
  22. {devnomads_cli-0.11.1 → devnomads_cli-0.11.2}/tests/test_transfer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.11.1
3
+ Version: 0.11.2
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -46,16 +46,20 @@ devnomads-cli`) work as well.
46
46
 
47
47
  ### Updates
48
48
 
49
- Update with:
49
+ Upgrade with:
50
50
 
51
51
  ```sh
52
- uv tool upgrade devnomads-cli
52
+ dncli upgrade
53
53
  ```
54
54
 
55
+ It uses the tool you installed dncli with (uv, pipx, or pip in a
56
+ virtualenv); where it can't, such as a system-wide pip install or on
57
+ Windows, it prints the command to run instead. `dncli upgrade --dry-run`
58
+ shows what it would do.
59
+
55
60
  dncli checks PyPI at most once a day and tells you when a new version
56
- is out, with the command to update the way you installed it. It only
57
- does so on an interactive terminal, never in pipes, scripts or CI; set
58
- `DN_NO_UPDATE_CHECK=1` to turn it off.
61
+ is out. It only does so on an interactive terminal, never in pipes,
62
+ scripts or CI; set `DN_NO_UPDATE_CHECK=1` to turn it off.
59
63
 
60
64
  dncli also warns when the DevNomads API reports a major version it was
61
65
  not built for, and tells you when the API no longer supports your
@@ -30,16 +30,20 @@ devnomads-cli`) work as well.
30
30
 
31
31
  ### Updates
32
32
 
33
- Update with:
33
+ Upgrade with:
34
34
 
35
35
  ```sh
36
- uv tool upgrade devnomads-cli
36
+ dncli upgrade
37
37
  ```
38
38
 
39
+ It uses the tool you installed dncli with (uv, pipx, or pip in a
40
+ virtualenv); where it can't, such as a system-wide pip install or on
41
+ Windows, it prints the command to run instead. `dncli upgrade --dry-run`
42
+ shows what it would do.
43
+
39
44
  dncli checks PyPI at most once a day and tells you when a new version
40
- is out, with the command to update the way you installed it. It only
41
- does so on an interactive terminal, never in pipes, scripts or CI; set
42
- `DN_NO_UPDATE_CHECK=1` to turn it off.
45
+ is out. It only does so on an interactive terminal, never in pipes,
46
+ scripts or CI; set `DN_NO_UPDATE_CHECK=1` to turn it off.
43
47
 
44
48
  dncli also warns when the DevNomads API reports a major version it was
45
49
  not built for, and tells you when the API no longer supports your
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.11.1
3
+ Version: 0.11.2
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -46,16 +46,20 @@ devnomads-cli`) work as well.
46
46
 
47
47
  ### Updates
48
48
 
49
- Update with:
49
+ Upgrade with:
50
50
 
51
51
  ```sh
52
- uv tool upgrade devnomads-cli
52
+ dncli upgrade
53
53
  ```
54
54
 
55
+ It uses the tool you installed dncli with (uv, pipx, or pip in a
56
+ virtualenv); where it can't, such as a system-wide pip install or on
57
+ Windows, it prints the command to run instead. `dncli upgrade --dry-run`
58
+ shows what it would do.
59
+
55
60
  dncli checks PyPI at most once a day and tells you when a new version
56
- is out, with the command to update the way you installed it. It only
57
- does so on an interactive terminal, never in pipes, scripts or CI; set
58
- `DN_NO_UPDATE_CHECK=1` to turn it off.
61
+ is out. It only does so on an interactive terminal, never in pipes,
62
+ scripts or CI; set `DN_NO_UPDATE_CHECK=1` to turn it off.
59
63
 
60
64
  dncli also warns when the DevNomads API reports a major version it was
61
65
  not built for, and tells you when the API no longer supports your
@@ -31,6 +31,7 @@ import re
31
31
  import secrets
32
32
  import shutil
33
33
  import stat
34
+ import subprocess # nosec B404 - only for `dncli upgrade`
34
35
  import sys
35
36
  import threading
36
37
  import time
@@ -63,7 +64,7 @@ from rich.markup import escape
63
64
  from rich.table import Table
64
65
  from typer.core import TyperGroup
65
66
 
66
- __version__ = "0.11.1"
67
+ __version__ = "0.11.2"
67
68
 
68
69
  # Distribution names, for reporting versions via `dncli version`.
69
70
  CLI_PACKAGE = "devnomads-cli"
@@ -112,17 +113,70 @@ def _version_tuple(text: str | None) -> tuple[int, int, int] | None:
112
113
  return major, minor, patch
113
114
 
114
115
 
116
+ def install_method() -> str:
117
+ """How this copy of dncli was installed, from the marker files each
118
+ installer leaves in the environment it creates:
119
+
120
+ uv-tool (uv tool install), pipx, uvx (uv's throwaway environments),
121
+ venv (pip in a virtualenv), system (pip into a system or user Python)
122
+ or script (the single-file dncli.py)."""
123
+
124
+ prefix = Path(sys.prefix)
125
+ if _package_version(CLI_PACKAGE, "") == "":
126
+ return "script"
127
+ if (prefix / "uv-receipt.toml").is_file():
128
+ return "uv-tool"
129
+ if (prefix / "pipx_metadata.json").is_file():
130
+ return "pipx"
131
+ if any(part.startswith("archive-v") for part in prefix.parts):
132
+ return "uvx"
133
+ if sys.prefix != sys.base_prefix:
134
+ return "venv"
135
+ return "system"
136
+
137
+
138
+ def upgrade_argv(method: str) -> list[str] | None:
139
+ """The command that upgrades dncli in place, or None when dncli cannot
140
+ or should not run it itself. On Windows the running dncli.exe cannot be
141
+ replaced, so the upgrade is left to the user there."""
142
+
143
+ if sys.platform == "win32":
144
+ return None
145
+ if method == "uv-tool" and (uv := shutil.which("uv")):
146
+ return [uv, "tool", "upgrade", CLI_PACKAGE]
147
+ if method == "pipx" and (pipx := shutil.which("pipx")):
148
+ return [pipx, "upgrade", CLI_PACKAGE]
149
+ if method == "venv":
150
+ from importlib.util import find_spec
151
+
152
+ if find_spec("pip") is not None:
153
+ return [sys.executable, "-m", "pip", "install", "-U", CLI_PACKAGE]
154
+ if uv := shutil.which("uv"): # uv-made virtualenvs have no pip
155
+ return [uv, "pip", "install", "--python", sys.executable, "-U", CLI_PACKAGE]
156
+ return None
157
+
158
+
159
+ def manual_upgrade(method: str) -> str:
160
+ """How to upgrade by hand, for messages and when dncli cannot do it."""
161
+
162
+ return {
163
+ "uv-tool": f"uv tool upgrade {CLI_PACKAGE}",
164
+ "pipx": f"pipx upgrade {CLI_PACKAGE}",
165
+ "uvx": f"uvx --from {CLI_PACKAGE}@latest dncli (uvx caches the version "
166
+ "it ran last)",
167
+ "venv": f"pip install -U {CLI_PACKAGE}",
168
+ "system": f"pip install -U {CLI_PACKAGE} (with --user or sudo, as you "
169
+ "installed it)",
170
+ "script": "download the latest dncli.py",
171
+ }[method]
172
+
173
+
115
174
  def upgrade_command() -> str:
116
- """How to upgrade dncli, judged by how this copy was installed."""
175
+ """How to upgrade dncli, for notices: `dncli upgrade` when it can do the
176
+ job itself, otherwise the command to run by hand."""
117
177
 
118
- prefix = Path(sys.prefix).as_posix().lower()
119
- if "/uv/tools/" in prefix:
120
- return f"uv tool upgrade {CLI_PACKAGE}"
121
- if "/pipx/venvs/" in prefix:
122
- return f"pipx upgrade {CLI_PACKAGE}"
123
- if _package_version(CLI_PACKAGE, "") == "": # the single-file script
124
- return "download the latest dncli.py"
125
- return f"pip install -U {CLI_PACKAGE}"
178
+ method = install_method()
179
+ return "dncli upgrade" if upgrade_argv(method) else manual_upgrade(method)
126
180
 
127
181
 
128
182
  # ---------------------------------------------------------------------------
@@ -1382,6 +1436,80 @@ def version_cmd(
1382
1436
  render(state_from(ctx, output, columns), _versions())
1383
1437
 
1384
1438
 
1439
+ @app.command("upgrade")
1440
+ def upgrade_cmd(
1441
+ dry_run: Annotated[
1442
+ bool,
1443
+ typer.Option(
1444
+ "--dry-run", help="Show how dncli would upgrade, without doing it."
1445
+ ),
1446
+ ] = False,
1447
+ ) -> None:
1448
+ """Upgrade dncli to the latest release.
1449
+
1450
+ Uses the tool dncli was installed with (uv, pipx or pip in a
1451
+ virtualenv). Where dncli cannot do that itself, it prints the command
1452
+ to run."""
1453
+
1454
+ method = install_method()
1455
+ argv = upgrade_argv(method)
1456
+ latest = latest_version()
1457
+ latest_t, current_t = _version_tuple(latest), _version_tuple(__version__)
1458
+ newer = latest_t is not None and latest_t > (current_t or (0, 0, 0))
1459
+ status = (
1460
+ f"dncli {__version__}; latest release {latest}"
1461
+ if latest
1462
+ else f"dncli {__version__}; could not reach PyPI for the latest release"
1463
+ )
1464
+ err_console.print(status)
1465
+ if dry_run:
1466
+ err_console.print(f"installed with: {method}")
1467
+ command = " ".join(argv) if argv else manual_upgrade(method)
1468
+ err_console.print(
1469
+ f"would run: {command}" if argv else f"upgrade by hand: {command}"
1470
+ )
1471
+ return
1472
+ if latest is not None and not newer:
1473
+ err_console.print("already up to date")
1474
+ return
1475
+ if argv is None:
1476
+ raise CliError(
1477
+ f"dncli cannot upgrade this installation ({method}) itself; "
1478
+ f"run: {manual_upgrade(method)}"
1479
+ )
1480
+ err_console.print(f"running: {' '.join(argv)}")
1481
+ result = subprocess.run(argv, check=False) # nosec B603 - fixed argv, no shell
1482
+ if result.returncode != 0:
1483
+ raise CliError(f"upgrade failed (exit code {result.returncode})")
1484
+ installed = _installed_version()
1485
+ if installed and installed != __version__:
1486
+ err_console.print(f"dncli upgraded from {__version__} to {installed}")
1487
+ else:
1488
+ err_console.print("dncli is up to date")
1489
+ state = _read_update_state()
1490
+ state.update(latest=installed or latest or __version__, checked_at=time.time())
1491
+ _write_update_state(state)
1492
+
1493
+
1494
+ def _installed_version() -> str | None:
1495
+ """The dncli version now installed in this environment, read in a fresh
1496
+ interpreter because this process still has the old one loaded."""
1497
+
1498
+ result = subprocess.run( # nosec B603 - fixed argv, no shell
1499
+ [
1500
+ sys.executable,
1501
+ "-c",
1502
+ f"from importlib.metadata import version; print(version({CLI_PACKAGE!r}))",
1503
+ ],
1504
+ capture_output=True,
1505
+ text=True,
1506
+ check=False,
1507
+ )
1508
+ if result.returncode != 0:
1509
+ return None
1510
+ return result.stdout.strip() or None
1511
+
1512
+
1385
1513
  # --- configure -------------------------------------------------------------
1386
1514
 
1387
1515
 
@@ -9127,9 +9255,8 @@ def _write_update_state(state: dict[str, Any]) -> None:
9127
9255
  pass
9128
9256
 
9129
9257
 
9130
- def _fetch_latest_version() -> None:
9131
- """Ask PyPI for the latest release and remember it. Any failure is
9132
- ignored: the check must never get in the way of a command."""
9258
+ def latest_version() -> str | None:
9259
+ """The latest dncli release on PyPI, or None when PyPI cannot be reached."""
9133
9260
 
9134
9261
  try:
9135
9262
  response = httpx.get(
@@ -9137,8 +9264,17 @@ def _fetch_latest_version() -> None:
9137
9264
  timeout=UPDATE_CHECK_TIMEOUT,
9138
9265
  headers={"User-Agent": user_agent()},
9139
9266
  )
9140
- latest = response.json()["info"]["version"]
9267
+ return str(response.json()["info"]["version"])
9141
9268
  except Exception: # best effort: offline or a PyPI hiccup is fine
9269
+ return None
9270
+
9271
+
9272
+ def _fetch_latest_version() -> None:
9273
+ """Ask PyPI for the latest release and remember it. Any failure is
9274
+ ignored: the check must never get in the way of a command."""
9275
+
9276
+ latest = latest_version()
9277
+ if latest is None:
9142
9278
  return
9143
9279
  state = _read_update_state()
9144
9280
  state.update(latest=str(latest), checked_at=time.time())
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.11.1"
3
+ version = "0.11.2"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,271 @@
1
+ """Update notice, API version check and the user agent."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ import respx
7
+ from httpx import Response
8
+
9
+ import dncli
10
+
11
+
12
+ @pytest.fixture
13
+ def interactive(monkeypatch):
14
+ monkeypatch.delenv(dncli.ENV_NO_UPDATE_CHECK, raising=False)
15
+ monkeypatch.delenv("CI", raising=False)
16
+ monkeypatch.setattr(dncli.err_console, "_force_terminal", True)
17
+
18
+
19
+ def write_state(**state):
20
+ path = dncli._update_state_path()
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ path.write_text(json.dumps(state))
23
+
24
+
25
+ def test_version_tuple():
26
+ assert dncli._version_tuple("0.10.0") == (0, 10, 0)
27
+ assert dncli._version_tuple("2.1") == (2, 1, 0)
28
+ assert dncli._version_tuple("1.0.0rc1") == (1, 0, 0)
29
+ assert dncli._version_tuple("") is None
30
+ assert dncli._version_tuple(None) is None
31
+
32
+
33
+ def test_update_check_is_off_outside_a_terminal(monkeypatch):
34
+ monkeypatch.setattr(dncli.err_console, "_force_terminal", False)
35
+ assert not dncli._update_check_enabled()
36
+
37
+
38
+ def test_update_check_can_be_disabled(interactive, monkeypatch):
39
+ assert dncli._update_check_enabled()
40
+ monkeypatch.setenv(dncli.ENV_NO_UPDATE_CHECK, "1")
41
+ assert not dncli._update_check_enabled()
42
+ monkeypatch.delenv(dncli.ENV_NO_UPDATE_CHECK)
43
+ monkeypatch.setenv("CI", "true")
44
+ assert not dncli._update_check_enabled()
45
+
46
+
47
+ def test_notice_when_a_newer_version_is_out(interactive, capsys):
48
+ write_state(latest="99.0.0", checked_at=0)
49
+ dncli.notify_update()
50
+ assert "dncli 99.0.0 is available" in capsys.readouterr().err
51
+ # shown at most once a day
52
+ dncli.notify_update()
53
+ assert capsys.readouterr().err == ""
54
+
55
+
56
+ def test_no_notice_when_up_to_date(interactive, capsys):
57
+ write_state(latest=dncli.__version__, checked_at=0)
58
+ dncli.notify_update()
59
+ assert capsys.readouterr().err == ""
60
+
61
+
62
+ @respx.mock
63
+ def test_fetch_stores_the_latest_version():
64
+ respx.get(dncli.PYPI_URL).mock(
65
+ return_value=Response(200, json={"info": {"version": "1.2.3"}})
66
+ )
67
+ dncli._fetch_latest_version()
68
+ assert dncli._read_update_state()["latest"] == "1.2.3"
69
+
70
+
71
+ @respx.mock
72
+ def test_fetch_failures_are_ignored():
73
+ respx.get(dncli.PYPI_URL).mock(return_value=Response(500, text="down"))
74
+ dncli._fetch_latest_version()
75
+ assert dncli._read_update_state() == {}
76
+
77
+
78
+ def test_fresh_state_skips_the_background_check(interactive, monkeypatch):
79
+ started = []
80
+ monkeypatch.setattr(
81
+ dncli.threading, "Thread", lambda **kw: started.append(kw) or _Dummy()
82
+ )
83
+ write_state(latest="0.0.1", checked_at=dncli.time.time())
84
+ dncli.start_update_check()
85
+ assert started == []
86
+ write_state(latest="0.0.1", checked_at=0)
87
+ dncli.start_update_check()
88
+ assert len(started) == 1
89
+
90
+
91
+ class _Dummy:
92
+ def start(self):
93
+ pass
94
+
95
+
96
+ @pytest.fixture
97
+ def api_warning(monkeypatch):
98
+ monkeypatch.setattr(dncli, "_api_version_warned", False)
99
+ monkeypatch.setattr(dncli, "API_VERSION", "1.4.0")
100
+
101
+
102
+ def test_same_api_major_is_silent(api_warning, capsys):
103
+ dncli.check_api_version("1.9.2")
104
+ dncli.check_api_version(None)
105
+ assert capsys.readouterr().err == ""
106
+
107
+
108
+ def test_newer_api_major_warns_once(api_warning, capsys):
109
+ dncli.check_api_version("2.0.0")
110
+ dncli.check_api_version("2.0.0")
111
+ err = capsys.readouterr().err
112
+ assert err.count("warning:") == 1
113
+ assert "built for API 1.4.0" in err and "update dncli" in err
114
+
115
+
116
+ def test_older_api_major_warns(api_warning, capsys):
117
+ dncli.check_api_version("0.9.0")
118
+ assert "newer than the API" in capsys.readouterr().err
119
+
120
+
121
+ def test_upgrade_required_message():
122
+ message = dncli._error_message(426, "Upgrade Required")
123
+ assert "no longer supported" in message
124
+ assert dncli.upgrade_command() in message
125
+
126
+
127
+ @pytest.fixture
128
+ def env(tmp_path, monkeypatch):
129
+ """A fake installation prefix; returns a function that sets it up."""
130
+
131
+ def make(marker=None, base=None, parts=("tools", "devnomads-cli")):
132
+ prefix = tmp_path.joinpath(*parts)
133
+ prefix.mkdir(parents=True, exist_ok=True)
134
+ if marker:
135
+ (prefix / marker).write_text("")
136
+ monkeypatch.setattr(dncli.sys, "prefix", str(prefix))
137
+ monkeypatch.setattr(dncli.sys, "base_prefix", base or "/usr")
138
+ monkeypatch.setattr(dncli.sys, "platform", "linux")
139
+ return prefix
140
+
141
+ return make
142
+
143
+
144
+ def test_install_method_from_marker_files(env):
145
+ env("uv-receipt.toml")
146
+ assert dncli.install_method() == "uv-tool"
147
+ env("pipx_metadata.json", parts=("pipx", "venvs", "devnomads-cli"))
148
+ assert dncli.install_method() == "pipx"
149
+ env(parts=("cache", "uv", "archive-v0", "abc"))
150
+ assert dncli.install_method() == "uvx"
151
+ env(parts=("project", ".venv"))
152
+ assert dncli.install_method() == "venv"
153
+
154
+
155
+ def test_install_method_system_and_script(env, monkeypatch):
156
+ prefix = env()
157
+ monkeypatch.setattr(dncli.sys, "base_prefix", str(prefix))
158
+ assert dncli.install_method() == "system"
159
+ monkeypatch.setattr(dncli, "_package_version", lambda name, fallback="": "")
160
+ assert dncli.install_method() == "script"
161
+
162
+
163
+ def test_upgrade_argv_needs_the_installer(env, monkeypatch):
164
+ env("uv-receipt.toml")
165
+ monkeypatch.setattr(dncli.shutil, "which", lambda name: f"/bin/{name}")
166
+ assert dncli.upgrade_argv("uv-tool") == [
167
+ "/bin/uv",
168
+ "tool",
169
+ "upgrade",
170
+ "devnomads-cli",
171
+ ]
172
+ assert dncli.upgrade_command() == "dncli upgrade"
173
+ monkeypatch.setattr(dncli.shutil, "which", lambda name: None)
174
+ assert dncli.upgrade_argv("uv-tool") is None
175
+ assert dncli.upgrade_command() == "uv tool upgrade devnomads-cli"
176
+
177
+
178
+ def test_upgrade_is_left_to_the_user_on_windows_and_system_pip(env, monkeypatch):
179
+ env("uv-receipt.toml")
180
+ monkeypatch.setattr(dncli.shutil, "which", lambda name: f"/bin/{name}")
181
+ monkeypatch.setattr(dncli.sys, "platform", "win32")
182
+ assert dncli.upgrade_argv("uv-tool") is None
183
+ monkeypatch.setattr(dncli.sys, "platform", "linux")
184
+ assert dncli.upgrade_argv("system") is None
185
+ assert dncli.upgrade_argv("uvx") is None
186
+
187
+
188
+ @pytest.fixture
189
+ def upgrade(monkeypatch):
190
+ """Stub PyPI, the installer and subprocess for `dncli upgrade`."""
191
+
192
+ calls = []
193
+
194
+ class Done:
195
+ def __init__(self, returncode=0, stdout=""):
196
+ self.returncode, self.stdout = returncode, stdout
197
+
198
+ def run(argv, **kwargs):
199
+ calls.append(argv)
200
+ if argv[0] == dncli.sys.executable: # the version probe
201
+ return Done(stdout="99.0.0\n")
202
+ return Done()
203
+
204
+ monkeypatch.setattr(dncli, "latest_version", lambda: "99.0.0")
205
+ monkeypatch.setattr(dncli, "install_method", lambda: "uv-tool")
206
+ monkeypatch.setattr(
207
+ dncli, "upgrade_argv", lambda method: ["uv", "tool", "upgrade", "devnomads-cli"]
208
+ )
209
+ monkeypatch.setattr(dncli.subprocess, "run", run)
210
+ return calls
211
+
212
+
213
+ def test_upgrade_runs_the_installer(upgrade):
214
+ from typer.testing import CliRunner
215
+
216
+ result = CliRunner().invoke(dncli.app, ["upgrade"])
217
+ assert result.exit_code == 0, result.output
218
+ assert upgrade[0] == ["uv", "tool", "upgrade", "devnomads-cli"]
219
+ assert f"upgraded from {dncli.__version__} to 99.0.0" in result.stderr
220
+
221
+
222
+ def test_upgrade_dry_run_only_reports(upgrade):
223
+ from typer.testing import CliRunner
224
+
225
+ result = CliRunner().invoke(dncli.app, ["upgrade", "--dry-run"])
226
+ assert result.exit_code == 0
227
+ assert "installed with: uv-tool" in result.stderr
228
+ assert "would run: uv tool upgrade devnomads-cli" in result.stderr
229
+ assert upgrade == []
230
+
231
+
232
+ def test_upgrade_when_already_current(upgrade, monkeypatch):
233
+ from typer.testing import CliRunner
234
+
235
+ monkeypatch.setattr(dncli, "latest_version", lambda: dncli.__version__)
236
+ result = CliRunner().invoke(dncli.app, ["upgrade"])
237
+ assert result.exit_code == 0
238
+ assert "already up to date" in result.stderr
239
+ assert upgrade == []
240
+
241
+
242
+ def test_upgrade_explains_what_it_cannot_do(upgrade, monkeypatch):
243
+ from typer.testing import CliRunner
244
+
245
+ monkeypatch.setattr(dncli, "install_method", lambda: "system")
246
+ monkeypatch.setattr(dncli, "upgrade_argv", lambda method: None)
247
+ result = CliRunner().invoke(dncli.app, ["upgrade"])
248
+ assert result.exit_code == 1
249
+ assert "pip install -U devnomads-cli" in result.stderr
250
+ assert upgrade == []
251
+
252
+
253
+ def test_user_agent_names_dncli_library_and_python():
254
+ agent = dncli.user_agent()
255
+ assert agent.startswith(f"dncli/{dncli.__version__} devnomads/")
256
+ assert " python/3." in agent
257
+
258
+
259
+ @respx.mock
260
+ def test_api_version_header_is_checked_on_every_call(
261
+ write_profile, api_warning, monkeypatch
262
+ ):
263
+ from typer.testing import CliRunner
264
+
265
+ write_profile(api_key="testkey-1234567890", api_url="https://api.test")
266
+ respx.get("https://api.test/services/servers").mock(
267
+ return_value=Response(200, json=[], headers={"X-Api-Version": "2.0.0"})
268
+ )
269
+ result = CliRunner().invoke(dncli.app, ["--out", "json", "servers", "list"])
270
+ assert result.exit_code == 0
271
+ assert "the API reports 2.0.0" in result.stderr
@@ -1,157 +0,0 @@
1
- """Update notice, API version check and the user agent."""
2
-
3
- import json
4
-
5
- import pytest
6
- import respx
7
- from httpx import Response
8
-
9
- import dncli
10
-
11
-
12
- @pytest.fixture
13
- def interactive(monkeypatch):
14
- monkeypatch.delenv(dncli.ENV_NO_UPDATE_CHECK, raising=False)
15
- monkeypatch.delenv("CI", raising=False)
16
- monkeypatch.setattr(dncli.err_console, "_force_terminal", True)
17
-
18
-
19
- def write_state(**state):
20
- path = dncli._update_state_path()
21
- path.parent.mkdir(parents=True, exist_ok=True)
22
- path.write_text(json.dumps(state))
23
-
24
-
25
- def test_version_tuple():
26
- assert dncli._version_tuple("0.10.0") == (0, 10, 0)
27
- assert dncli._version_tuple("2.1") == (2, 1, 0)
28
- assert dncli._version_tuple("1.0.0rc1") == (1, 0, 0)
29
- assert dncli._version_tuple("") is None
30
- assert dncli._version_tuple(None) is None
31
-
32
-
33
- def test_update_check_is_off_outside_a_terminal(monkeypatch):
34
- monkeypatch.setattr(dncli.err_console, "_force_terminal", False)
35
- assert not dncli._update_check_enabled()
36
-
37
-
38
- def test_update_check_can_be_disabled(interactive, monkeypatch):
39
- assert dncli._update_check_enabled()
40
- monkeypatch.setenv(dncli.ENV_NO_UPDATE_CHECK, "1")
41
- assert not dncli._update_check_enabled()
42
- monkeypatch.delenv(dncli.ENV_NO_UPDATE_CHECK)
43
- monkeypatch.setenv("CI", "true")
44
- assert not dncli._update_check_enabled()
45
-
46
-
47
- def test_notice_when_a_newer_version_is_out(interactive, capsys):
48
- write_state(latest="99.0.0", checked_at=0)
49
- dncli.notify_update()
50
- assert "dncli 99.0.0 is available" in capsys.readouterr().err
51
- # shown at most once a day
52
- dncli.notify_update()
53
- assert capsys.readouterr().err == ""
54
-
55
-
56
- def test_no_notice_when_up_to_date(interactive, capsys):
57
- write_state(latest=dncli.__version__, checked_at=0)
58
- dncli.notify_update()
59
- assert capsys.readouterr().err == ""
60
-
61
-
62
- @respx.mock
63
- def test_fetch_stores_the_latest_version():
64
- respx.get(dncli.PYPI_URL).mock(
65
- return_value=Response(200, json={"info": {"version": "1.2.3"}})
66
- )
67
- dncli._fetch_latest_version()
68
- assert dncli._read_update_state()["latest"] == "1.2.3"
69
-
70
-
71
- @respx.mock
72
- def test_fetch_failures_are_ignored():
73
- respx.get(dncli.PYPI_URL).mock(return_value=Response(500, text="down"))
74
- dncli._fetch_latest_version()
75
- assert dncli._read_update_state() == {}
76
-
77
-
78
- def test_fresh_state_skips_the_background_check(interactive, monkeypatch):
79
- started = []
80
- monkeypatch.setattr(
81
- dncli.threading, "Thread", lambda **kw: started.append(kw) or _Dummy()
82
- )
83
- write_state(latest="0.0.1", checked_at=dncli.time.time())
84
- dncli.start_update_check()
85
- assert started == []
86
- write_state(latest="0.0.1", checked_at=0)
87
- dncli.start_update_check()
88
- assert len(started) == 1
89
-
90
-
91
- class _Dummy:
92
- def start(self):
93
- pass
94
-
95
-
96
- @pytest.fixture
97
- def api_warning(monkeypatch):
98
- monkeypatch.setattr(dncli, "_api_version_warned", False)
99
- monkeypatch.setattr(dncli, "API_VERSION", "1.4.0")
100
-
101
-
102
- def test_same_api_major_is_silent(api_warning, capsys):
103
- dncli.check_api_version("1.9.2")
104
- dncli.check_api_version(None)
105
- assert capsys.readouterr().err == ""
106
-
107
-
108
- def test_newer_api_major_warns_once(api_warning, capsys):
109
- dncli.check_api_version("2.0.0")
110
- dncli.check_api_version("2.0.0")
111
- err = capsys.readouterr().err
112
- assert err.count("warning:") == 1
113
- assert "built for API 1.4.0" in err and "update dncli" in err
114
-
115
-
116
- def test_older_api_major_warns(api_warning, capsys):
117
- dncli.check_api_version("0.9.0")
118
- assert "newer than the API" in capsys.readouterr().err
119
-
120
-
121
- def test_upgrade_required_message():
122
- message = dncli._error_message(426, "Upgrade Required")
123
- assert "no longer supported" in message
124
- assert dncli.upgrade_command() in message
125
-
126
-
127
- @pytest.mark.parametrize(
128
- "prefix, expected",
129
- [
130
- ("/home/u/.local/share/uv/tools/devnomads-cli", "uv tool upgrade"),
131
- ("/home/u/.local/pipx/venvs/devnomads-cli", "pipx upgrade"),
132
- ],
133
- )
134
- def test_upgrade_command_follows_the_install_method(monkeypatch, prefix, expected):
135
- monkeypatch.setattr(dncli.sys, "prefix", prefix)
136
- assert dncli.upgrade_command().startswith(expected)
137
-
138
-
139
- def test_user_agent_names_dncli_library_and_python():
140
- agent = dncli.user_agent()
141
- assert agent.startswith(f"dncli/{dncli.__version__} devnomads/")
142
- assert " python/3." in agent
143
-
144
-
145
- @respx.mock
146
- def test_api_version_header_is_checked_on_every_call(
147
- write_profile, api_warning, monkeypatch
148
- ):
149
- from typer.testing import CliRunner
150
-
151
- write_profile(api_key="testkey-1234567890", api_url="https://api.test")
152
- respx.get("https://api.test/services/servers").mock(
153
- return_value=Response(200, json=[], headers={"X-Api-Version": "2.0.0"})
154
- )
155
- result = CliRunner().invoke(dncli.app, ["--out", "json", "servers", "list"])
156
- assert result.exit_code == 0
157
- assert "the API reports 2.0.0" in result.stderr
File without changes
File without changes