datalegion-cli 1.3.0__tar.gz → 1.3.1__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. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/PKG-INFO +2 -2
  2. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/README.md +1 -1
  3. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/pyproject.toml +1 -1
  4. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/config.py +1 -0
  5. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/login.py +10 -5
  6. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/main.py +9 -2
  7. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/test_login.py +30 -6
  8. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/.gitignore +0 -0
  9. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/__init__.py +0 -0
  10. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/async_typer.py +0 -0
  11. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/client.py +0 -0
  12. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/__init__.py +0 -0
  13. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/company.py +0 -0
  14. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/credits.py +0 -0
  15. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/person.py +0 -0
  16. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/utility.py +0 -0
  17. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/logging.py +0 -0
  18. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/output.py +0 -0
  19. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/src/datalegion_cli/update.py +0 -0
  20. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/__init__.py +0 -0
  21. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/conftest.py +0 -0
  22. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/test_client.py +0 -0
  23. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/test_commands.py +0 -0
  24. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/test_config.py +0 -0
  25. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/test_logging.py +0 -0
  26. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/test_output.py +0 -0
  27. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/test_package_contents.py +0 -0
  28. {datalegion_cli-1.3.0 → datalegion_cli-1.3.1}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datalegion-cli
3
- Version: 1.3.0
3
+ Version: 1.3.1
4
4
  Summary: CLI for the Data Legion API — agent-friendly, fully async
5
5
  Project-URL: Homepage, https://www.datalegion.ai
6
6
  Project-URL: Documentation, https://www.datalegion.ai/docs
@@ -104,7 +104,7 @@ datalegion-cli login --team-id 7 # when your account has several teams
104
104
  datalegion-cli login --no-browser # headless: prints the link to open on another device
105
105
  ```
106
106
 
107
- The key belongs to this machine (named after its hostname, or `--device <name>`). Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
107
+ The key belongs to this machine, named with a random id (`cli-` plus 8 hex characters) that the CLI generates on first login and keeps in its config, so nothing about the machine (such as its hostname) is sent. Pass `--device <name>` for a readable label instead. Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
108
108
 
109
109
  Or set an API key yourself (stored encrypted on disk):
110
110
 
@@ -70,7 +70,7 @@ datalegion-cli login --team-id 7 # when your account has several teams
70
70
  datalegion-cli login --no-browser # headless: prints the link to open on another device
71
71
  ```
72
72
 
73
- The key belongs to this machine (named after its hostname, or `--device <name>`). Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
73
+ The key belongs to this machine, named with a random id (`cli-` plus 8 hex characters) that the CLI generates on first login and keeps in its config, so nothing about the machine (such as its hostname) is sent. Pass `--device <name>` for a readable label instead. Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
74
74
 
75
75
  Or set an API key yourself (stored encrypted on disk):
76
76
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datalegion-cli"
3
- version = "1.3.0"
3
+ version = "1.3.1"
4
4
  description = "CLI for the Data Legion API — agent-friendly, fully async"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -27,6 +27,7 @@ _DEFAULTS: dict[str, object] = {
27
27
  "api_key": "",
28
28
  "default_output": "json", # json | table | raw
29
29
  "install_method": "", # brew | github | git | pip
30
+ "device_id": "", # random name for this machine's login key (see login.new_device_id)
30
31
  }
31
32
 
32
33
  # Fields that are stored encrypted on disk
@@ -12,8 +12,8 @@ from __future__ import annotations
12
12
 
13
13
  import asyncio
14
14
  import os
15
- import platform
16
15
  import re
16
+ import secrets
17
17
  from typing import TYPE_CHECKING, Any
18
18
 
19
19
  if TYPE_CHECKING:
@@ -40,10 +40,15 @@ class LoginError(Exception):
40
40
  self.details = details
41
41
 
42
42
 
43
- def default_device_name() -> str:
44
- """This machine's name in the form the API accepts, or 'cli' when it has none."""
45
- name = re.sub(r"[^\w.@:-]+", "-", platform.node()).strip("-")[:100]
46
- return name or "cli"
43
+ def new_device_id() -> str:
44
+ """A random name for this machine's key: ``cli-`` plus 8 hex characters.
45
+
46
+ Random rather than the hostname (or a hash of it): hostnames often carry a
47
+ person's name and are guessable, so nothing about the machine leaves it. Saved
48
+ in the config on first login and reused, so signing in again replaces this
49
+ machine's key instead of adding another.
50
+ """
51
+ return f"cli-{secrets.token_hex(4)}"
47
52
 
48
53
 
49
54
  def check_device_name(name: str) -> str:
@@ -126,7 +126,10 @@ async def login(
126
126
  int | None, typer.Option("--team-id", help="Team the key bills, when you have several.")
127
127
  ] = None,
128
128
  device: Annotated[
129
- str | None, typer.Option("--device", help="Name for this machine (default: hostname).")
129
+ str | None,
130
+ typer.Option(
131
+ "--device", help="Name for this machine (default: a random id saved locally)."
132
+ ),
130
133
  ] = None,
131
134
  no_browser: Annotated[
132
135
  bool, typer.Option("--no-browser", help="Print the sign-in link without opening it.")
@@ -145,7 +148,11 @@ async def login(
145
148
  from datalegion_cli import login as flow
146
149
 
147
150
  try:
148
- device_name = flow.check_device_name(device or flow.default_device_name())
151
+ cfg = load_config()
152
+ if not device and not cfg.get("device_id"):
153
+ cfg["device_id"] = flow.new_device_id()
154
+ save_config(cfg)
155
+ device_name = flow.check_device_name(device or str(cfg["device_id"]))
149
156
  async with httpx.AsyncClient(timeout=30.0) as client:
150
157
  started = await flow.start_device_authorization(client)
151
158
  link = started.get("verification_uri_complete") or started["verification_uri"]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  from unittest.mock import AsyncMock, patch
7
8
 
8
9
  import httpx
@@ -32,13 +33,15 @@ def tmp_config(tmp_config_dir, monkeypatch):
32
33
 
33
34
 
34
35
  class TestDeviceName:
35
- def test_default_is_the_hostname_made_safe(self, monkeypatch):
36
- monkeypatch.setattr(flow.platform, "node", lambda: "Jack's MacBook Pro.local")
37
- assert flow.default_device_name() == "Jack-s-MacBook-Pro.local"
36
+ def test_new_ids_are_random_and_api_safe(self):
37
+ ids = {flow.new_device_id() for _ in range(50)}
38
+ assert len(ids) == 50
39
+ for device_id in ids:
40
+ assert re.fullmatch(r"cli-[0-9a-f]{8}", device_id)
41
+ assert flow.check_device_name(device_id) == device_id
38
42
 
39
- def test_default_without_a_hostname(self, monkeypatch):
40
- monkeypatch.setattr(flow.platform, "node", lambda: "")
41
- assert flow.default_device_name() == "cli"
43
+ def test_the_hostname_is_never_read(self):
44
+ assert not hasattr(flow, "platform")
42
45
 
43
46
  @pytest.mark.parametrize("name", ["", " lead", "a\nb", "x" * 101, "semi;colon", "two spaces"])
44
47
  def test_rejects_what_the_api_rejects(self, name):
@@ -181,6 +184,27 @@ class TestLoginCommand:
181
184
 
182
185
  assert load_config()["api_key"] == KEY
183
186
 
187
+ def test_default_device_is_a_saved_random_id_reused_next_time(self, steps, tmp_config):
188
+ exchange, _ = steps
189
+ assert runner.invoke(app, ["login"]).exit_code == 0
190
+ first = exchange.await_args.args[3]
191
+ assert re.fullmatch(r"cli-[0-9a-f]{8}", first)
192
+ from datalegion_cli.config import load_config
193
+
194
+ assert load_config()["device_id"] == first
195
+ assert runner.invoke(app, ["login"]).exit_code == 0
196
+ assert exchange.await_args.args[3] == first # same machine, same key slot
197
+
198
+ def test_device_flag_wins_and_leaves_the_saved_id(self, steps, tmp_config):
199
+ exchange, _ = steps
200
+ assert runner.invoke(app, ["login"]).exit_code == 0
201
+ from datalegion_cli.config import load_config
202
+
203
+ saved = load_config()["device_id"]
204
+ assert runner.invoke(app, ["login", "--device", "laptop"]).exit_code == 0
205
+ assert exchange.await_args.args[3] == "laptop"
206
+ assert load_config()["device_id"] == saved
207
+
184
208
  def test_no_browser(self, steps, tmp_config):
185
209
  _, browser = steps
186
210
  assert runner.invoke(app, ["login", "--device", "laptop", "--no-browser"]).exit_code == 0