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.
- datalegion_cli-1.5.2/LICENSE +21 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/PKG-INFO +6 -6
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/README.md +3 -3
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/pyproject.toml +13 -5
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/company.py +5 -5
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/person.py +4 -5
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/config.py +2 -7
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/update.py +44 -27
- {datalegion_cli-1.5.0 → 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.0 → datalegion_cli-1.5.2}/tests/test_update.py +112 -10
- datalegion_cli-1.5.0/tests/test_package_contents.py +0 -56
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/.gitignore +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/__init__.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/async_typer.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/client.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/__init__.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/credits.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/commands/utility.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/logging.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/login.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/main.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/src/datalegion_cli/output.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/__init__.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/conftest.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_commands.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_config.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_logging.py +0 -0
- {datalegion_cli-1.5.0 → datalegion_cli-1.5.2}/tests/test_login.py +0 -0
- {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.
|
|
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 = [
|
|
@@ -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,
|
|
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
|
|
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
|
|
111
|
-
datalegion-cli company search "name
|
|
112
|
-
datalegion-cli company search "
|
|
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 "
|
|
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
|
|
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 = '
|
|
137
|
-
datalegion-cli person search "city = '
|
|
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
|
|
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
|
|
@@ -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(
|
|
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
|
|
99
|
-
|
|
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.
|
|
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
|
|
107
|
-
#
|
|
108
|
-
|
|
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.
|
|
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)
|
|
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
|