datalegion-cli 1.5.1__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.
- datalegion_cli-1.5.2/LICENSE +21 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/PKG-INFO +6 -6
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/README.md +3 -3
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/pyproject.toml +9 -5
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/config.py +2 -7
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/update.py +44 -27
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/tests/test_client.py +5 -2
- datalegion_cli-1.5.2/tests/test_package_contents.py +112 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/tests/test_update.py +104 -9
- datalegion_cli-1.5.1/tests/_conventions.py +0 -175
- datalegion_cli-1.5.1/tests/test_conventions.py +0 -30
- datalegion_cli-1.5.1/tests/test_package_contents.py +0 -56
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/.gitignore +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/__init__.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/async_typer.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/client.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/__init__.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/company.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/credits.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/person.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/utility.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/logging.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/login.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/main.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/src/datalegion_cli/output.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/tests/__init__.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/tests/conftest.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/tests/test_commands.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/tests/test_config.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/tests/test_logging.py +0 -0
- {datalegion_cli-1.5.1 → datalegion_cli-1.5.2}/tests/test_login.py +0 -0
- {datalegion_cli-1.5.1 → 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.
|
|
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
|
|
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` |
|
|
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` |
|
|
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
|
|
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` |
|
|
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` |
|
|
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.
|
|
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
|
|
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 = [
|
|
@@ -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
|
|
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
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
@@ -102,28 +110,115 @@ class TestDetectInstallMethod:
|
|
|
102
110
|
with patch("datalegion_cli.config.load_config", return_value={"install_method": "github"}):
|
|
103
111
|
assert await _detect_install_method() == "github"
|
|
104
112
|
|
|
105
|
-
async def
|
|
106
|
-
|
|
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)
|
|
107
116
|
with (
|
|
108
117
|
patch("datalegion_cli.config.load_config", return_value={"install_method": ""}),
|
|
109
|
-
patch("datalegion_cli.update.
|
|
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),
|
|
110
137
|
):
|
|
111
138
|
assert await _detect_install_method() == "git"
|
|
112
139
|
|
|
113
|
-
async def
|
|
114
|
-
#
|
|
115
|
-
|
|
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"
|
|
116
145
|
fake_file.parent.mkdir(parents=True)
|
|
117
146
|
fake_file.touch()
|
|
118
|
-
|
|
119
147
|
with (
|
|
120
148
|
patch("datalegion_cli.config.load_config", return_value={"install_method": ""}),
|
|
121
|
-
patch("datalegion_cli.update.
|
|
149
|
+
patch("datalegion_cli.update.sys.prefix", str(tmp_path / ".venv")),
|
|
122
150
|
patch("datalegion_cli.update.__file__", str(fake_file)),
|
|
151
|
+
patch("datalegion_cli.update._source_checkout", return_value=None),
|
|
123
152
|
):
|
|
124
153
|
assert await _detect_install_method() == "pip"
|
|
125
154
|
|
|
126
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
|
+
|
|
127
222
|
@pytest.mark.asyncio
|
|
128
223
|
class TestSelfUpdate:
|
|
129
224
|
async def test_uses_uv_when_pip_method(self):
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
# Vendored from datalegion-shared v0.4.97 (src/datalegion_shared/testing/conventions.py). Do not edit: re-copy it; the upgrade sweep diffs it.
|
|
2
|
-
"""The test-suite conventions every Data Legion repo holds, checked from a repo's tracked files.
|
|
3
|
-
|
|
4
|
-
1. Every test can fail: it asserts, raises or expects a raise (``pytest.raises``/``warns``, a mock
|
|
5
|
-
``assert_*``, ``fail``), itself or through a helper anywhere in the tests (``conftest.py``
|
|
6
|
-
included, followed transitively). A test whose whole claim is that valid input runs says so in
|
|
7
|
-
its name (``..._does_not_raise`` / ``..._do_not_raise``) and can fail by raising.
|
|
8
|
-
2. No test swallows an exception with ``except ...: pass``: it passes whatever happened.
|
|
9
|
-
3. No two tests share a body (decorators included): the second name's behavior goes untested.
|
|
10
|
-
|
|
11
|
-
Standard library only, and nothing else from datalegion_shared imports this or is imported here:
|
|
12
|
-
data-pipeline's standardization caches fingerprint every shared module their code imports, so this
|
|
13
|
-
module must stay outside that closure. Repos that don't depend on datalegion-shared vendor a
|
|
14
|
-
byte-identical copy of this file; the upgrade sweep diffs the copies against this one.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
import ast
|
|
20
|
-
import re
|
|
21
|
-
import subprocess
|
|
22
|
-
from pathlib import Path
|
|
23
|
-
|
|
24
|
-
TEST_FILES = ("*test_*.py", "*_test.py")
|
|
25
|
-
HELPER_FILES = ("*tests/*.py", "*conftest.py")
|
|
26
|
-
NO_RAISE_SUFFIXES = ("_does_not_raise", "_do_not_raise")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _tracked(root: Path, patterns: tuple[str, ...]) -> dict[str, ast.Module]:
|
|
30
|
-
names = subprocess.run(
|
|
31
|
-
["git", "ls-files", *patterns], cwd=root, capture_output=True, text=True, check=True
|
|
32
|
-
).stdout.split()
|
|
33
|
-
return {
|
|
34
|
-
name: ast.parse((root / name).read_text(encoding="utf-8")) for name in sorted(set(names))
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _functions(tree: ast.AST) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
39
|
-
return [n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Test = tuple[tuple[ast.ClassDef, ...], ast.FunctionDef | ast.AsyncFunctionDef]
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _tests(tree: ast.Module) -> list[Test]:
|
|
46
|
-
"""Return the tests pytest collects, each with the classes it sits in.
|
|
47
|
-
|
|
48
|
-
Module-level ``test*`` and ``test*`` methods of ``Test*`` classes, a ``Test*`` class nested in
|
|
49
|
-
one included. A ``test*`` function nested in a fixture or another function (a route handler named
|
|
50
|
-
``test_endpoint``) is no test.
|
|
51
|
-
"""
|
|
52
|
-
found: list[Test] = []
|
|
53
|
-
|
|
54
|
-
def collect(body: list[ast.stmt], classes: tuple[ast.ClassDef, ...]) -> None:
|
|
55
|
-
for node in body:
|
|
56
|
-
if isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
|
|
57
|
-
collect(node.body, (*classes, node))
|
|
58
|
-
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
59
|
-
if node.name.startswith("test"):
|
|
60
|
-
found.append((classes, node))
|
|
61
|
-
|
|
62
|
-
collect(tree.body, ())
|
|
63
|
-
return found
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _where(name: str, classes: tuple[ast.ClassDef, ...], fn: ast.AST) -> str:
|
|
67
|
-
"""``file::Class::test``, so two same-named methods in two classes can be told apart."""
|
|
68
|
-
return "::".join([name, *(cls.name for cls in classes), fn.name])
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _checks_directly(fn: ast.AST) -> bool:
|
|
72
|
-
for node in ast.walk(fn):
|
|
73
|
-
if isinstance(node, (ast.Assert, ast.Raise)):
|
|
74
|
-
return True
|
|
75
|
-
if isinstance(node, (ast.With, ast.AsyncWith)) and any(
|
|
76
|
-
re.search(r"\b(raises|warns)\(", ast.unparse(item.context_expr)) for item in node.items
|
|
77
|
-
):
|
|
78
|
-
return True
|
|
79
|
-
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
80
|
-
if node.func.attr.startswith("assert") or node.func.attr == "fail":
|
|
81
|
-
return True
|
|
82
|
-
return False
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _called_names(fn: ast.AST) -> set[str]:
|
|
86
|
-
names = set()
|
|
87
|
-
for node in ast.walk(fn):
|
|
88
|
-
if isinstance(node, ast.Call):
|
|
89
|
-
if isinstance(node.func, ast.Name):
|
|
90
|
-
names.add(node.func.id)
|
|
91
|
-
elif isinstance(node.func, ast.Attribute):
|
|
92
|
-
names.add(node.func.attr)
|
|
93
|
-
return names
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def silent_tests(
|
|
97
|
-
root: Path, tests: tuple[str, ...] = TEST_FILES, helpers: tuple[str, ...] = HELPER_FILES
|
|
98
|
-
) -> list[str]:
|
|
99
|
-
"""Return every test that can't fail: no assertion or raise, itself or through a helper."""
|
|
100
|
-
trees = _tracked(root, tests)
|
|
101
|
-
by_name = {
|
|
102
|
-
fn.name: fn
|
|
103
|
-
for tree in [*_tracked(root, helpers).values(), *trees.values()]
|
|
104
|
-
for fn in _functions(tree)
|
|
105
|
-
if not fn.name.startswith("test")
|
|
106
|
-
}
|
|
107
|
-
checking = {name for name, fn in by_name.items() if _checks_directly(fn)}
|
|
108
|
-
while more := {n for n, fn in by_name.items() if _called_names(fn) & checking} - checking:
|
|
109
|
-
checking |= more # a helper calling a checking helper checks too
|
|
110
|
-
return [
|
|
111
|
-
_where(name, classes, fn)
|
|
112
|
-
for name, tree in trees.items()
|
|
113
|
-
for classes, fn in _tests(tree)
|
|
114
|
-
if not _checks_directly(fn)
|
|
115
|
-
and not (_called_names(fn) & checking)
|
|
116
|
-
and not fn.name.endswith(NO_RAISE_SUFFIXES)
|
|
117
|
-
]
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def swallowing_tests(root: Path, tests: tuple[str, ...] = TEST_FILES) -> list[str]:
|
|
121
|
-
"""Return every ``except ...: pass`` inside a test, as ``file::Class::test:line``."""
|
|
122
|
-
found = []
|
|
123
|
-
for name, tree in _tracked(root, tests).items():
|
|
124
|
-
for classes, fn in _tests(tree):
|
|
125
|
-
for node in ast.walk(fn):
|
|
126
|
-
if isinstance(node, ast.Try) and any(
|
|
127
|
-
all(
|
|
128
|
-
isinstance(stmt, ast.Pass)
|
|
129
|
-
or (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant))
|
|
130
|
-
for stmt in handler.body
|
|
131
|
-
)
|
|
132
|
-
for handler in node.handlers
|
|
133
|
-
):
|
|
134
|
-
found.append(f"{_where(name, classes, fn)}:{node.lineno}")
|
|
135
|
-
return found
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _self_members_used(fn: ast.AST, cls: ast.ClassDef | None) -> list[ast.AST]:
|
|
139
|
-
"""Return the definitions of the ``self.X`` members a method uses, from its own class."""
|
|
140
|
-
if cls is None:
|
|
141
|
-
return []
|
|
142
|
-
used = {
|
|
143
|
-
n.attr
|
|
144
|
-
for n in ast.walk(fn)
|
|
145
|
-
if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name) and n.value.id == "self"
|
|
146
|
-
}
|
|
147
|
-
members = []
|
|
148
|
-
for node in cls.body:
|
|
149
|
-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in used:
|
|
150
|
-
members.append(node)
|
|
151
|
-
elif isinstance(node, (ast.Assign, ast.AnnAssign)) and any(
|
|
152
|
-
isinstance(t, ast.Name) and t.id in used
|
|
153
|
-
for t in (node.targets if isinstance(node, ast.Assign) else [node.target])
|
|
154
|
-
):
|
|
155
|
-
members.append(node)
|
|
156
|
-
return members
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def duplicate_test_bodies(root: Path, tests: tuple[str, ...] = TEST_FILES) -> list[list[str]]:
|
|
160
|
-
"""Return groups of tests with one body (decorators included, docstrings ignored).
|
|
161
|
-
|
|
162
|
-
A method's body includes the definitions of the ``self`` helpers it calls: twin person and
|
|
163
|
-
company test classes share bodies but call their own ``self._slot``, so they test different
|
|
164
|
-
code and are no duplicates.
|
|
165
|
-
"""
|
|
166
|
-
by_body: dict[str, list[str]] = {}
|
|
167
|
-
for name, tree in _tracked(root, tests).items():
|
|
168
|
-
for classes, fn in _tests(tree):
|
|
169
|
-
body = fn.body
|
|
170
|
-
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
|
|
171
|
-
body = body[1:]
|
|
172
|
-
used = _self_members_used(fn, classes[-1] if classes else None)
|
|
173
|
-
key = ast.dump(ast.Module(body=[*fn.decorator_list, *body, *used], type_ignores=[]))
|
|
174
|
-
by_body.setdefault(key, []).append(_where(name, classes, fn))
|
|
175
|
-
return [names for names in by_body.values() if len(names) > 1]
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
"""The fleet's test conventions, from the vendored copy of datalegion-shared's module.
|
|
2
|
-
|
|
3
|
-
Every test can fail, no test swallows an exception, and no two tests share a body. This repo
|
|
4
|
-
doesn't depend on datalegion-shared, so ``_conventions.py`` is a byte-identical copy under a
|
|
5
|
-
header naming the release it came from; the copy itself is checked here.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
from tests import _conventions as conventions
|
|
11
|
-
|
|
12
|
-
REPO = Path(__file__).resolve().parents[1]
|
|
13
|
-
VENDORED = Path(conventions.__file__)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def test_every_test_can_fail() -> None:
|
|
17
|
-
assert conventions.silent_tests(REPO) == []
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_no_test_swallows_an_exception() -> None:
|
|
21
|
-
assert conventions.swallowing_tests(REPO) == []
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_no_two_tests_share_a_body() -> None:
|
|
25
|
-
assert conventions.duplicate_test_bodies(REPO) == []
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_the_vendored_copy_names_its_release() -> None:
|
|
29
|
-
header = VENDORED.read_text().splitlines()[0]
|
|
30
|
-
assert header.startswith("# Vendored from datalegion-shared v")
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|