datalegion-cli 1.5.0__tar.gz → 1.5.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 (30) hide show
  1. datalegion_cli-1.5.2/LICENSE +21 -0
  2. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/PKG-INFO +6 -6
  3. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/README.md +3 -3
  4. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/pyproject.toml +13 -5
  5. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/company.py +5 -5
  6. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/person.py +4 -5
  7. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/config.py +2 -7
  8. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/update.py +44 -27
  9. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_client.py +5 -2
  10. datalegion_cli-1.5.2/tests/test_package_contents.py +112 -0
  11. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_update.py +112 -10
  12. datalegion_cli-1.5.0/tests/test_package_contents.py +0 -56
  13. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/.gitignore +0 -0
  14. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/__init__.py +0 -0
  15. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/async_typer.py +0 -0
  16. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/client.py +0 -0
  17. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/__init__.py +0 -0
  18. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/credits.py +0 -0
  19. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/utility.py +0 -0
  20. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/logging.py +0 -0
  21. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/login.py +0 -0
  22. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/main.py +0 -0
  23. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/output.py +0 -0
  24. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/__init__.py +0 -0
  25. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/conftest.py +0 -0
  26. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_commands.py +0 -0
  27. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_config.py +0 -0
  28. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_logging.py +0 -0
  29. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_login.py +0 -0
  30. {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_output.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Data Legion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datalegion-cli
3
- Version: 1.5.0
3
+ Version: 1.5.2
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
@@ -8,6 +8,7 @@ Project-URL: Repository, https://github.com/datalegion-ai/datalegion-cli
8
8
  Project-URL: Issues, https://github.com/datalegion-ai/datalegion-cli/issues
9
9
  Author-email: Data Legion <engineering@datalegion.ai>
10
10
  License-Expression: MIT
11
+ License-File: LICENSE
11
12
  Keywords: b2b,cli,company-enrichment,contact-data,data-enrichment,datalegion,enrichment,person-enrichment
12
13
  Classifier: Development Status :: 4 - Beta
13
14
  Classifier: Environment :: Console
@@ -19,8 +20,8 @@ Classifier: Programming Language :: Python :: 3.12
19
20
  Classifier: Programming Language :: Python :: 3.13
20
21
  Classifier: Programming Language :: Python :: 3.14
21
22
  Classifier: Programming Language :: Python :: 3.15
22
- Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.11
24
+ Requires-Dist: certifi>=2026.10.19
24
25
  Requires-Dist: cryptography>=44.0.0
25
26
  Requires-Dist: httpx>=0.28.0
26
27
  Requires-Dist: loguru>=0.7.0
@@ -29,7 +30,6 @@ Requires-Dist: pydantic-settings>=2.6.0
29
30
  Requires-Dist: pydantic>=2.9.0
30
31
  Requires-Dist: rich>=13.9.0
31
32
  Requires-Dist: tomli-w>=1.0.0
32
- Requires-Dist: tomli>=2.0.0; python_version < '3.11'
33
33
  Requires-Dist: typer>=0.15.0
34
34
  Description-Content-Type: text/markdown
35
35
 
@@ -50,7 +50,7 @@ datalegion-cli person enrich --email john@example.com
50
50
  - **Fully async** — async from entry point to HTTP client, powered by `asyncio` and `httpx`
51
51
  - **Agent-friendly** — all output is JSON to stdout, logs go to stderr, supports `--stdin` for piping
52
52
  - **Encrypted config** — API keys are stored encrypted at rest (Fernet + PBKDF2, machine-tied)
53
- - **Auto-update** — checks GitHub releases and self-updates via Homebrew, GitHub release, git pull, or pip/uv
53
+ - **Auto-update** — checks PyPI for a newer release and self-updates via Homebrew, GitHub release, git pull, or pip/uv
54
54
  - **Multiple install methods** — Homebrew, GitHub release, pip/uv (with private index support), or from source
55
55
 
56
56
  ## Installation
@@ -143,9 +143,9 @@ The `update` command auto-detects how the CLI was installed:
143
143
 
144
144
  | Method | Detection | Update strategy |
145
145
  |--------|-----------|-----------------|
146
- | `brew` | `brew list` finds the formula | `brew upgrade` |
146
+ | `brew` | The running Python is the formula's own venv under Homebrew's Cellar | `brew upgrade` |
147
147
  | `github` | Set via config | Downloads `.whl` from GitHub release |
148
- | `git` | `.git` directory in parent tree | `git pull --ff-only` + `uv sync` |
148
+ | `git` | An editable install from a git checkout (its `direct_url.json`) | `git pull --ff-only` + `uv sync` in that checkout |
149
149
  | `pip` | Default fallback | `uv pip install --upgrade` |
150
150
 
151
151
  You can override detection by setting `install_method` in config. For pip installs from a private index, set the `LEGION_PIP_INDEX_URL` environment variable.
@@ -15,7 +15,7 @@ datalegion-cli person enrich --email john@example.com
15
15
  - **Fully async** — async from entry point to HTTP client, powered by `asyncio` and `httpx`
16
16
  - **Agent-friendly** — all output is JSON to stdout, logs go to stderr, supports `--stdin` for piping
17
17
  - **Encrypted config** — API keys are stored encrypted at rest (Fernet + PBKDF2, machine-tied)
18
- - **Auto-update** — checks GitHub releases and self-updates via Homebrew, GitHub release, git pull, or pip/uv
18
+ - **Auto-update** — checks PyPI for a newer release and self-updates via Homebrew, GitHub release, git pull, or pip/uv
19
19
  - **Multiple install methods** — Homebrew, GitHub release, pip/uv (with private index support), or from source
20
20
 
21
21
  ## Installation
@@ -108,9 +108,9 @@ The `update` command auto-detects how the CLI was installed:
108
108
 
109
109
  | Method | Detection | Update strategy |
110
110
  |--------|-----------|-----------------|
111
- | `brew` | `brew list` finds the formula | `brew upgrade` |
111
+ | `brew` | The running Python is the formula's own venv under Homebrew's Cellar | `brew upgrade` |
112
112
  | `github` | Set via config | Downloads `.whl` from GitHub release |
113
- | `git` | `.git` directory in parent tree | `git pull --ff-only` + `uv sync` |
113
+ | `git` | An editable install from a git checkout (its `direct_url.json`) | `git pull --ff-only` + `uv sync` in that checkout |
114
114
  | `pip` | Default fallback | `uv pip install --upgrade` |
115
115
 
116
116
  You can override detection by setting `install_method` in config. For pip installs from a private index, set the `LEGION_PIP_INDEX_URL` environment variable.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datalegion-cli"
3
- version = "1.5.0"
3
+ version = "1.5.2"
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,15 +27,16 @@ classifiers = [
27
27
  "Programming Language :: Python :: 3.14",
28
28
  "Programming Language :: Python :: 3.15",
29
29
  "Environment :: Console",
30
- "Typing :: Typed",
31
30
  ]
32
31
  dependencies = [
33
32
  "typer>=0.15.0",
34
33
  "httpx>=0.28.0",
34
+ # httpx verifies TLS against certifi's bundle. 2026.10.19 drops emSign ECC Root CA - G3, whose
35
+ # intermediates were compromised (GHSA-6q48-6c7w-9gw2), so upgrading the CLI fixes it.
36
+ "certifi>=2026.10.19",
35
37
  "pydantic>=2.9.0",
36
38
  "pydantic-settings>=2.6.0",
37
39
  "rich>=13.9.0",
38
- "tomli>=2.0.0; python_version < '3.11'",
39
40
  "tomli-w>=1.0.0",
40
41
  "packaging>=24.0",
41
42
  "loguru>=0.7.0",
@@ -58,9 +59,12 @@ build-backend = "hatchling.build"
58
59
  [tool.hatch.build.targets.wheel]
59
60
  packages = ["src/datalegion_cli"]
60
61
 
61
- # The sdist carries the source and tests, never repo tooling (.github/, .claude/, lefthook.yml).
62
+ # The sdist carries the source, tests and license, never repo tooling (.github/, .claude/,
63
+ # lefthook.yml). Its tests must pass from the sdist alone, so the conventions tests, which read
64
+ # the git index, stay out.
62
65
  [tool.hatch.build.targets.sdist]
63
- only-include = ["src/datalegion_cli", "tests"]
66
+ only-include = ["src/datalegion_cli", "tests", "LICENSE"]
67
+ exclude = ["tests/test_conventions.py", "tests/_conventions.py"]
64
68
 
65
69
  [dependency-groups]
66
70
  dev = [
@@ -91,6 +95,10 @@ ignore = [
91
95
  "E501", # line length (handled by formatter)
92
96
  ]
93
97
 
98
+ [tool.ruff.lint.per-file-ignores]
99
+ # A byte-identical copy of datalegion-shared's module, held to shared's lint, not edited here
100
+ "tests/_conventions.py" = ["SIM", "TC"]
101
+
94
102
  [tool.ruff.lint.isort]
95
103
  known-first-party = ["datalegion_cli"]
96
104
 
@@ -63,7 +63,7 @@ async def enrich(
63
63
  Examples:
64
64
  datalegion-cli company enrich --domain google.com
65
65
  datalegion-cli company enrich --name "Acme Corp" --industry "Software"
66
- datalegion-cli company enrich --ticker AAPL --fields "name,industry,revenue"
66
+ datalegion-cli company enrich --ticker AAPL --fields "name,industry,legion_employee_count"
67
67
  echo '{"domain":"stripe.com"}' | datalegion-cli company enrich --stdin
68
68
  """
69
69
  if stdin:
@@ -104,12 +104,12 @@ async def search(
104
104
  include_fields: FieldsInclude = None,
105
105
  exclude_fields: FieldsExclude = None,
106
106
  ) -> None:
107
- """Search for companies using SQL WHERE syntax (Beta).
107
+ """Search for companies with a SQL SELECT over the companies table, no LIMIT (Beta).
108
108
 
109
109
  Examples:
110
- datalegion-cli company search "industry = 'Software' AND employee_count > 100"
111
- datalegion-cli company search "name LIKE '%tech%'" --limit 20
112
- datalegion-cli company search "revenue > 1000000" --fields "name,revenue,domain"
110
+ datalegion-cli company search "SELECT * FROM companies WHERE industry ILIKE '%software%' AND legion_employee_count > 100"
111
+ datalegion-cli company search "SELECT * FROM companies WHERE name ILIKE '%tech%'" --limit 20
112
+ datalegion-cli company search "SELECT * FROM companies WHERE domain = 'stripe.com'" --fields "name,domain,industry"
113
113
  """
114
114
  client = LegionClient()
115
115
  try:
@@ -78,7 +78,7 @@ async def enrich(
78
78
  Examples:
79
79
  datalegion-cli person enrich --email john@example.com
80
80
  datalegion-cli person enrich --name "Jane Doe" --company "Acme Corp"
81
- datalegion-cli person enrich --phone "+14155551234" --fields "name,email,title"
81
+ datalegion-cli person enrich --phone "+14155551234" --fields "full_name,work_email,job_title"
82
82
  echo '{"email":"jane@example.com"}' | datalegion-cli person enrich --stdin
83
83
  """
84
84
  if stdin:
@@ -130,12 +130,11 @@ async def search(
130
130
  include_fields: FieldsInclude = None,
131
131
  exclude_fields: FieldsExclude = None,
132
132
  ) -> None:
133
- """Search for people using SQL WHERE syntax (Beta).
133
+ """Search for people with a SQL SELECT over the people table, no LIMIT (Beta).
134
134
 
135
135
  Examples:
136
- datalegion-cli person search "job_title = 'CEO' AND company = 'Acme Corp'"
137
- datalegion-cli person search "city = 'San Francisco'" --limit 20
138
- datalegion-cli person search "school = 'MIT'" --fields "name,email,company"
136
+ datalegion-cli person search "SELECT * FROM people WHERE job_title = 'ceo' AND company_name ILIKE '%acme%'"
137
+ datalegion-cli person search "SELECT * FROM people WHERE city = 'san francisco'" --limit 20
139
138
  """
140
139
  client = LegionClient()
141
140
  try:
@@ -7,18 +7,13 @@ import getpass
7
7
  import hashlib
8
8
  import os
9
9
  import platform
10
+ import tomllib
10
11
  from pathlib import Path
11
12
 
13
+ import tomli_w
12
14
  from cryptography.fernet import Fernet, InvalidToken
13
15
  from loguru import logger
14
16
 
15
- try:
16
- import tomllib
17
- except ModuleNotFoundError: # Python < 3.11
18
- import tomli as tomllib # type: ignore[no-redef]
19
-
20
- import tomli_w
21
-
22
17
  CONFIG_DIR = Path(os.environ.get("LEGION_CONFIG_DIR", Path.home() / ".datalegion"))
23
18
  CONFIG_FILE = CONFIG_DIR / "config.toml"
24
19
 
@@ -1,13 +1,17 @@
1
- """Auto-update — checks GitHub releases and self-updates via brew, github, git, uv, or pip."""
1
+ """Auto-update — checks PyPI for a newer release and self-updates via brew, github, git, uv, or pip."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  import os
7
8
  import shutil
8
9
  import sys
9
10
  import tempfile
11
+ from importlib.metadata import PackageNotFoundError, distribution
10
12
  from pathlib import Path
13
+ from urllib.parse import urlparse
14
+ from urllib.request import url2pathname
11
15
 
12
16
  import httpx
13
17
  from loguru import logger
@@ -22,6 +26,38 @@ GITHUB_REPO_URL = f"https://github.com/{GITHUB_REPO}.git"
22
26
  BREW_TAP = "datalegion-ai/tap"
23
27
  BREW_FORMULA = "datalegion-cli"
24
28
  PKG_NAME = "datalegion_cli"
29
+ DIST_NAME = "datalegion-cli"
30
+
31
+
32
+ def _source_checkout() -> Path | None:
33
+ """The git checkout this CLI is installed from in editable mode, or None.
34
+
35
+ Read from the install's own record (PEP 610 ``direct_url.json``), never by walking up from
36
+ the package: a wheel installed into a project's ``.venv`` sits inside that project's git
37
+ repo, and treating it as a checkout would ``git pull`` and ``uv sync`` the customer's project.
38
+ """
39
+ try:
40
+ raw = distribution(DIST_NAME).read_text("direct_url.json")
41
+ except PackageNotFoundError:
42
+ return None
43
+ if not raw:
44
+ return None
45
+ try:
46
+ record = json.loads(raw)
47
+ except ValueError:
48
+ return None
49
+ if not record.get("dir_info", {}).get("editable"):
50
+ return None
51
+ url = urlparse(record.get("url", ""))
52
+ if url.scheme != "file":
53
+ return None
54
+ root = Path(url2pathname(url.path))
55
+ return root if (root / ".git").exists() else None
56
+
57
+
58
+ def _installed_by_brew() -> bool:
59
+ """True when this interpreter is the formula's own venv under Homebrew's Cellar."""
60
+ return f"/Cellar/{BREW_FORMULA}/" in Path(sys.prefix).resolve().as_posix()
25
61
 
26
62
 
27
63
  async def check_for_update(*, quiet: bool = False) -> str | None:
@@ -57,25 +93,12 @@ async def _detect_install_method() -> str:
57
93
  if method in ("brew", "github", "git", "pip"):
58
94
  return method
59
95
 
60
- # Check if installed via Homebrew
61
- if brew := shutil.which("brew"):
62
- proc = await asyncio.create_subprocess_exec(
63
- brew,
64
- "list",
65
- BREW_FORMULA,
66
- stdout=asyncio.subprocess.DEVNULL,
67
- stderr=asyncio.subprocess.DEVNULL,
68
- )
69
- await proc.wait()
70
- if proc.returncode == 0:
71
- return "brew"
72
-
73
- # Check if running from a git clone
74
- pkg_dir = Path(__file__).resolve().parent
75
- for parent in pkg_dir.parents:
76
- if (parent / ".git").exists():
77
- return "git"
78
-
96
+ # The running interpreter decides, not what else is on the machine: a pip copy on a
97
+ # machine that also has the formula is still a pip copy.
98
+ if _installed_by_brew():
99
+ return "brew"
100
+ if _source_checkout() is not None:
101
+ return "git"
79
102
  return "pip"
80
103
 
81
104
 
@@ -168,13 +191,7 @@ async def _download_asset(url: str, dest_dir: str) -> Path:
168
191
 
169
192
  async def _update_git() -> None:
170
193
  """Update a local git clone via git pull + uv sync."""
171
- pkg_dir = Path(__file__).resolve().parent
172
- repo_root: Path | None = None
173
- for parent in pkg_dir.parents:
174
- if (parent / ".git").exists():
175
- repo_root = parent
176
- break
177
-
194
+ repo_root = _source_checkout()
178
195
  if repo_root is None:
179
196
  logger.error("Could not find git repo root.")
180
197
  raise SystemExit(1)
@@ -248,9 +248,12 @@ def test_sandbox_flag_turns_it_on(monkeypatch):
248
248
 
249
249
  from datalegion_cli.main import app
250
250
 
251
- monkeypatch.delenv("LEGION_SANDBOX", raising=False)
251
+ # setenv first so monkeypatch records the variable's original state and restores it. A
252
+ # delenv after the CLI set it would record "1" as the value to put back, leaking sandbox
253
+ # mode into every later test.
254
+ monkeypatch.setenv("LEGION_SANDBOX", "")
255
+ monkeypatch.delenv("LEGION_SANDBOX")
252
256
  CliRunner().invoke(app, ["--sandbox", "version"])
253
257
  import os
254
258
 
255
259
  assert os.environ.get("LEGION_SANDBOX") == "1"
256
- monkeypatch.delenv("LEGION_SANDBOX", raising=False)
@@ -0,0 +1,112 @@
1
+ """What the published wheel and sdist contain.
2
+
3
+ Customers install the wheel: it must hold only the ``datalegion_cli`` package, and its only
4
+ command is ``datalegion-cli``. The sdist carries the source and tests, never repo tooling.
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import tarfile
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def dist(tmp_path_factory: pytest.TempPathFactory) -> Path:
21
+ out = tmp_path_factory.mktemp("dist")
22
+ # Each straight from the source tree: a default build makes the wheel from the sdist, which
23
+ # would hide a wheel misconfiguration behind the sdist's own include list.
24
+ for target in ("--sdist", "--wheel"):
25
+ subprocess.run(
26
+ ["uv", "build", target, "--quiet", "--out-dir", str(out)], cwd=ROOT, check=True
27
+ )
28
+ return out
29
+
30
+
31
+ def test_wheel_ships_only_the_cli(dist: Path) -> None:
32
+ (wheel,) = dist.glob("*.whl")
33
+ with zipfile.ZipFile(wheel) as archive:
34
+ names = archive.namelist()
35
+ commands = next(
36
+ (archive.read(n).decode() for n in names if n.endswith("entry_points.txt")), ""
37
+ )
38
+ top_level = {n.split("/")[0] for n in names if not n.split("/")[0].endswith(".dist-info")}
39
+ assert top_level == {"datalegion_cli"}
40
+ assert any(n.endswith(".dist-info/licenses/LICENSE") for n in names)
41
+ assert commands.split() == [
42
+ "[console_scripts]",
43
+ "datalegion-cli",
44
+ "=",
45
+ "datalegion_cli.main:run",
46
+ ]
47
+
48
+
49
+ def test_sdist_carries_no_repo_tooling(dist: Path) -> None:
50
+ (sdist,) = dist.glob("*.tar.gz")
51
+ with tarfile.open(sdist) as archive:
52
+ names = archive.getnames()
53
+ leaked = [
54
+ n
55
+ for n in names
56
+ if any(part in n.split("/") for part in (".claude", ".github", "scripts", "lefthook.yml"))
57
+ ]
58
+ assert leaked == []
59
+ assert any(n.endswith("src/datalegion_cli/main.py") for n in names)
60
+
61
+
62
+ def test_sdist_carries_the_license_and_no_git_bound_tests(dist: Path) -> None:
63
+ (sdist,) = dist.glob("*.tar.gz")
64
+ with tarfile.open(sdist) as archive:
65
+ names = {n.split("/", 1)[1] for n in archive.getnames() if "/" in n}
66
+ assert "LICENSE" in names
67
+ assert "tests/test_conventions.py" not in names
68
+ assert "tests/_conventions.py" not in names
69
+
70
+
71
+ def test_sdist_tests_pass_from_the_sdist_alone(dist: Path, tmp_path: Path) -> None:
72
+ (sdist,) = dist.glob("*.tar.gz")
73
+ with tarfile.open(sdist) as archive:
74
+ archive.extractall(tmp_path, filter="data")
75
+ (root,) = [p for p in tmp_path.iterdir() if p.is_dir()]
76
+ # A clean environment: nothing from this run (LEGION_* settings an earlier test left, the
77
+ # user's own config) may reach the sdist's tests.
78
+ env = {k: v for k, v in os.environ.items() if not k.startswith(("LEGION_", "DATALEGION_"))}
79
+ env["HOME"] = str(tmp_path / "home")
80
+ # The sdist's own source first on the path, on the interpreter running these tests (it has
81
+ # every dependency already), so nothing is installed or fetched: no network, no resolution.
82
+ # Code comes from the sdist; package metadata (version, direct_url.json) still comes from the
83
+ # running environment's install, so a test here must not rely on how the package was installed.
84
+ env["PYTHONPATH"] = str(root / "src")
85
+ result = subprocess.run(
86
+ [
87
+ sys.executable,
88
+ "-m",
89
+ "pytest",
90
+ "-q",
91
+ "-p",
92
+ "no:cacheprovider",
93
+ "tests",
94
+ "--ignore",
95
+ "tests/test_package_contents.py",
96
+ ],
97
+ cwd=root,
98
+ env=env,
99
+ capture_output=True,
100
+ text=True,
101
+ )
102
+ imported = subprocess.run(
103
+ [sys.executable, "-c", "import datalegion_cli; print(datalegion_cli.__file__)"],
104
+ cwd=root,
105
+ env=env,
106
+ capture_output=True,
107
+ text=True,
108
+ check=True,
109
+ ).stdout.strip()
110
+ # The tests exercised the sdist's copy, not this checkout's installed one.
111
+ assert Path(imported).resolve().is_relative_to((root / "src").resolve())
112
+ assert result.returncode == 0, result.stdout[-2000:]
@@ -2,12 +2,20 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  from unittest.mock import AsyncMock, patch
6
7
 
7
8
  import httpx
8
9
  import pytest
9
10
 
10
- from datalegion_cli.update import _detect_install_method, _run, check_for_update, self_update
11
+ from datalegion_cli.update import (
12
+ _detect_install_method,
13
+ _run,
14
+ _source_checkout,
15
+ _update_git,
16
+ check_for_update,
17
+ self_update,
18
+ )
11
19
 
12
20
 
13
21
  @pytest.mark.asyncio
@@ -74,9 +82,16 @@ class TestRun:
74
82
  mock_proc.communicate = AsyncMock(return_value=(b"output", b""))
75
83
  mock_proc.returncode = 0
76
84
 
77
- with patch("datalegion_cli.update.asyncio.create_subprocess_exec", return_value=mock_proc):
85
+ with patch(
86
+ "datalegion_cli.update.asyncio.create_subprocess_exec", return_value=mock_proc
87
+ ) as exec_:
78
88
  await _run(["echo", "hello"])
79
89
 
90
+ exec_.assert_awaited_once()
91
+ assert exec_.await_args.args == ("echo", "hello")
92
+ assert exec_.await_args.kwargs["cwd"] is None
93
+ mock_proc.communicate.assert_awaited_once()
94
+
80
95
  async def test_failed_command_raises(self):
81
96
  mock_proc = AsyncMock()
82
97
  mock_proc.communicate = AsyncMock(return_value=(b"", b"some error"))
@@ -95,28 +110,115 @@ class TestDetectInstallMethod:
95
110
  with patch("datalegion_cli.config.load_config", return_value={"install_method": "github"}):
96
111
  assert await _detect_install_method() == "github"
97
112
 
98
- async def test_detects_git_when_in_repo(self):
99
- # Running from a git clone, should detect "git"
113
+ async def test_detects_brew_from_the_running_interpreter(self, tmp_path):
114
+ prefix = tmp_path / "Cellar" / "datalegion-cli" / "1.5.2" / "libexec" / "venv"
115
+ prefix.mkdir(parents=True)
100
116
  with (
101
117
  patch("datalegion_cli.config.load_config", return_value={"install_method": ""}),
102
- patch("datalegion_cli.update.shutil.which", return_value=None),
118
+ patch("datalegion_cli.update.sys.prefix", str(prefix)),
119
+ ):
120
+ assert await _detect_install_method() == "brew"
121
+
122
+ async def test_pip_copy_is_pip_even_when_brew_has_the_formula(self, tmp_path):
123
+ # brew on PATH says nothing about which copy is running.
124
+ with (
125
+ patch("datalegion_cli.config.load_config", return_value={"install_method": ""}),
126
+ patch("datalegion_cli.update.sys.prefix", str(tmp_path / ".venv")),
127
+ patch("datalegion_cli.update.shutil.which", return_value="/opt/homebrew/bin/brew"),
128
+ patch("datalegion_cli.update._source_checkout", return_value=None),
129
+ ):
130
+ assert await _detect_install_method() == "pip"
131
+
132
+ async def test_detects_git_for_an_editable_checkout(self, tmp_path):
133
+ with (
134
+ patch("datalegion_cli.config.load_config", return_value={"install_method": ""}),
135
+ patch("datalegion_cli.update.sys.prefix", str(tmp_path / ".venv")),
136
+ patch("datalegion_cli.update._source_checkout", return_value=tmp_path),
103
137
  ):
104
138
  assert await _detect_install_method() == "git"
105
139
 
106
- async def test_falls_back_to_pip(self, tmp_path):
107
- # Point __file__ to a path with no .git parent
108
- fake_file = tmp_path / "pkg" / "update.py"
140
+ async def test_wheel_inside_a_customers_repo_is_pip(self, tmp_path):
141
+ # A wheel installed into a project's .venv sits under that project's .git; updating
142
+ # must never git pull or uv sync the customer's own repository.
143
+ (tmp_path / ".git").mkdir()
144
+ fake_file = tmp_path / ".venv" / "lib" / "site-packages" / "datalegion_cli" / "update.py"
109
145
  fake_file.parent.mkdir(parents=True)
110
146
  fake_file.touch()
111
-
112
147
  with (
113
148
  patch("datalegion_cli.config.load_config", return_value={"install_method": ""}),
114
- patch("datalegion_cli.update.shutil.which", return_value=None),
149
+ patch("datalegion_cli.update.sys.prefix", str(tmp_path / ".venv")),
115
150
  patch("datalegion_cli.update.__file__", str(fake_file)),
151
+ patch("datalegion_cli.update._source_checkout", return_value=None),
116
152
  ):
117
153
  assert await _detect_install_method() == "pip"
118
154
 
119
155
 
156
+ class _Dist:
157
+ def __init__(self, direct_url: str | None) -> None:
158
+ self._direct_url = direct_url
159
+
160
+ def read_text(self, name: str) -> str | None:
161
+ return self._direct_url if name == "direct_url.json" else None
162
+
163
+
164
+ class TestSourceCheckout:
165
+ def _with(self, direct_url: str | None) -> object:
166
+ return patch("datalegion_cli.update.distribution", return_value=_Dist(direct_url))
167
+
168
+ def test_editable_install_from_a_git_checkout(self, tmp_path):
169
+ (tmp_path / ".git").mkdir()
170
+ record = {"url": tmp_path.as_uri(), "dir_info": {"editable": True}}
171
+ with self._with(json.dumps(record)):
172
+ assert _source_checkout() == tmp_path
173
+
174
+ @pytest.mark.parametrize(
175
+ "record",
176
+ [
177
+ None,
178
+ "not json",
179
+ json.dumps({"url": "https://files.pythonhosted.org/x.whl", "archive_info": {}}),
180
+ json.dumps({"url": "file:///tmp/x", "dir_info": {}}),
181
+ ],
182
+ ids=["wheel-from-pypi", "unreadable", "archive", "non-editable-dir"],
183
+ )
184
+ def test_anything_but_an_editable_checkout_is_none(self, record):
185
+ with self._with(record):
186
+ assert _source_checkout() is None
187
+
188
+ def test_editable_install_without_git_is_none(self, tmp_path):
189
+ record = {"url": tmp_path.as_uri(), "dir_info": {"editable": True}}
190
+ with self._with(json.dumps(record)):
191
+ assert _source_checkout() is None
192
+
193
+
194
+ @pytest.mark.asyncio
195
+ class TestUpdateGit:
196
+ async def test_pulls_and_syncs_the_checkout_it_came_from(self, tmp_path):
197
+ with (
198
+ patch("datalegion_cli.update._source_checkout", return_value=tmp_path),
199
+ patch("datalegion_cli.update.shutil.which", return_value="/usr/bin/uv"),
200
+ patch("datalegion_cli.update._run", new_callable=AsyncMock) as mock_run,
201
+ ):
202
+ await _update_git()
203
+ assert mock_run.call_args_list[0][0][0] == [
204
+ "git",
205
+ "-C",
206
+ str(tmp_path),
207
+ "pull",
208
+ "--ff-only",
209
+ ]
210
+ assert mock_run.call_args_list[1].kwargs["cwd"] == tmp_path
211
+
212
+ async def test_refuses_without_a_checkout(self):
213
+ with (
214
+ patch("datalegion_cli.update._source_checkout", return_value=None),
215
+ patch("datalegion_cli.update._run", new_callable=AsyncMock) as mock_run,
216
+ pytest.raises(SystemExit),
217
+ ):
218
+ await _update_git()
219
+ mock_run.assert_not_called()
220
+
221
+
120
222
  @pytest.mark.asyncio
121
223
  class TestSelfUpdate:
122
224
  async def test_uses_uv_when_pip_method(self):
@@ -1,56 +0,0 @@
1
- """What the published wheel and sdist contain.
2
-
3
- Customers install the wheel: it must hold only the ``datalegion_cli`` package, and its only
4
- command is ``datalegion-cli``. The sdist carries the source and tests, never repo tooling.
5
- """
6
-
7
- import subprocess
8
- import tarfile
9
- import zipfile
10
- from pathlib import Path
11
-
12
- import pytest
13
-
14
- ROOT = Path(__file__).resolve().parents[1]
15
-
16
-
17
- @pytest.fixture(scope="module")
18
- def dist(tmp_path_factory: pytest.TempPathFactory) -> Path:
19
- out = tmp_path_factory.mktemp("dist")
20
- # Each straight from the source tree: a default build makes the wheel from the sdist, which
21
- # would hide a wheel misconfiguration behind the sdist's own include list.
22
- for target in ("--sdist", "--wheel"):
23
- subprocess.run(
24
- ["uv", "build", target, "--quiet", "--out-dir", str(out)], cwd=ROOT, check=True
25
- )
26
- return out
27
-
28
-
29
- def test_wheel_ships_only_the_cli(dist: Path) -> None:
30
- (wheel,) = dist.glob("*.whl")
31
- with zipfile.ZipFile(wheel) as archive:
32
- names = archive.namelist()
33
- commands = next(
34
- (archive.read(n).decode() for n in names if n.endswith("entry_points.txt")), ""
35
- )
36
- top_level = {n.split("/")[0] for n in names if not n.split("/")[0].endswith(".dist-info")}
37
- assert top_level == {"datalegion_cli"}
38
- assert commands.split() == [
39
- "[console_scripts]",
40
- "datalegion-cli",
41
- "=",
42
- "datalegion_cli.main:run",
43
- ]
44
-
45
-
46
- def test_sdist_carries_no_repo_tooling(dist: Path) -> None:
47
- (sdist,) = dist.glob("*.tar.gz")
48
- with tarfile.open(sdist) as archive:
49
- names = archive.getnames()
50
- leaked = [
51
- n
52
- for n in names
53
- if any(part in n.split("/") for part in (".claude", ".github", "scripts", "lefthook.yml"))
54
- ]
55
- assert leaked == []
56
- assert any(n.endswith("src/datalegion_cli/main.py") for n in names)