python-snacks 0.1.4__tar.gz → 0.2.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.
- {python_snacks-0.1.4/python_snacks.egg-info → python_snacks-0.2.2}/PKG-INFO +1 -1
- {python_snacks-0.1.4 → python_snacks-0.2.2}/pyproject.toml +1 -1
- {python_snacks-0.1.4 → python_snacks-0.2.2/python_snacks.egg-info}/PKG-INFO +1 -1
- {python_snacks-0.1.4 → python_snacks-0.2.2}/python_snacks.egg-info/SOURCES.txt +1 -0
- python_snacks-0.2.2/snacks/auth.py +115 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/snacks/main.py +35 -9
- python_snacks-0.2.2/snacks/ops.py +168 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/tests/test_commands.py +19 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/tests/test_stash_commands.py +103 -1
- python_snacks-0.1.4/snacks/ops.py +0 -126
- {python_snacks-0.1.4 → python_snacks-0.2.2}/LICENSE +0 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/README.md +0 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/python_snacks.egg-info/dependency_links.txt +0 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/python_snacks.egg-info/entry_points.txt +0 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/python_snacks.egg-info/requires.txt +0 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/python_snacks.egg-info/top_level.txt +0 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/setup.cfg +0 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/snacks/__init__.py +0 -0
- {python_snacks-0.1.4 → python_snacks-0.2.2}/snacks/config.py +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""GitHub authentication for snack CLI.
|
|
2
|
+
|
|
3
|
+
Resolution order:
|
|
4
|
+
1. GITHUB_TOKEN env var (useful for CI)
|
|
5
|
+
2. `gh auth token` (GitHub CLI, if installed and logged in)
|
|
6
|
+
3. GitHub OAuth device flow (prompts user in browser)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
# Register at https://github.com/settings/apps/new (Device Flow, no client secret needed)
|
|
22
|
+
_CLIENT_ID = os.environ.get("SNACK_GITHUB_CLIENT_ID", "")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_github_token() -> Optional[str]:
|
|
26
|
+
"""Return a GitHub token, prompting via device flow if needed."""
|
|
27
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
28
|
+
if token:
|
|
29
|
+
return token
|
|
30
|
+
|
|
31
|
+
token = _token_from_gh_cli()
|
|
32
|
+
if token:
|
|
33
|
+
return token
|
|
34
|
+
|
|
35
|
+
return _device_flow()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _token_from_gh_cli() -> Optional[str]:
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["gh", "auth", "token"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=5,
|
|
45
|
+
)
|
|
46
|
+
if result.returncode == 0:
|
|
47
|
+
return result.stdout.strip()
|
|
48
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
49
|
+
pass
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _device_flow() -> Optional[str]:
|
|
54
|
+
if not _CLIENT_ID:
|
|
55
|
+
typer.echo(
|
|
56
|
+
"[error] No GitHub client ID configured. "
|
|
57
|
+
"Set SNACK_GITHUB_CLIENT_ID or GITHUB_TOKEN to authenticate.",
|
|
58
|
+
err=True,
|
|
59
|
+
)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
# Step 1 — request device + user code
|
|
63
|
+
data = urllib.parse.urlencode({"client_id": _CLIENT_ID, "scope": "repo"}).encode()
|
|
64
|
+
req = urllib.request.Request(
|
|
65
|
+
"https://github.com/login/device/code",
|
|
66
|
+
data=data,
|
|
67
|
+
headers={"Accept": "application/json"},
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
with urllib.request.urlopen(req) as resp:
|
|
71
|
+
payload = json.loads(resp.read())
|
|
72
|
+
except urllib.error.URLError as e:
|
|
73
|
+
typer.echo(f"[error] Could not reach GitHub: {e.reason}", err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
device_code = payload["device_code"]
|
|
77
|
+
user_code = payload["user_code"]
|
|
78
|
+
verification_uri = payload["verification_uri"]
|
|
79
|
+
interval = payload.get("interval", 5)
|
|
80
|
+
expires_in = payload.get("expires_in", 900)
|
|
81
|
+
|
|
82
|
+
typer.echo(f"\nOpen: {verification_uri}")
|
|
83
|
+
typer.echo(f"Code: {user_code}\n")
|
|
84
|
+
|
|
85
|
+
# Step 2 — poll until the user approves
|
|
86
|
+
deadline = time.time() + expires_in
|
|
87
|
+
while time.time() < deadline:
|
|
88
|
+
time.sleep(interval)
|
|
89
|
+
|
|
90
|
+
data = urllib.parse.urlencode({
|
|
91
|
+
"client_id": _CLIENT_ID,
|
|
92
|
+
"device_code": device_code,
|
|
93
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
94
|
+
}).encode()
|
|
95
|
+
req = urllib.request.Request(
|
|
96
|
+
"https://github.com/login/oauth/access_token",
|
|
97
|
+
data=data,
|
|
98
|
+
headers={"Accept": "application/json"},
|
|
99
|
+
)
|
|
100
|
+
with urllib.request.urlopen(req) as resp:
|
|
101
|
+
result = json.loads(resp.read())
|
|
102
|
+
|
|
103
|
+
if "access_token" in result:
|
|
104
|
+
typer.echo("Authenticated.")
|
|
105
|
+
return result["access_token"]
|
|
106
|
+
|
|
107
|
+
error = result.get("error")
|
|
108
|
+
if error == "slow_down":
|
|
109
|
+
interval += 5
|
|
110
|
+
elif error != "authorization_pending":
|
|
111
|
+
typer.echo(f"[error] Authentication failed: {error}", err=True)
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
|
|
114
|
+
typer.echo("[error] Authentication timed out.", err=True)
|
|
115
|
+
raise typer.Exit(1)
|
|
@@ -11,6 +11,7 @@ import typer
|
|
|
11
11
|
from snacks.config import SnackConfig, get_stash_path
|
|
12
12
|
from snacks.ops import add_remote as do_add_remote
|
|
13
13
|
from snacks.ops import pack as do_pack, unpack as do_unpack
|
|
14
|
+
from snacks.ops import read_index
|
|
14
15
|
|
|
15
16
|
app = typer.Typer(
|
|
16
17
|
name="snack",
|
|
@@ -65,11 +66,7 @@ def list_snacks(
|
|
|
65
66
|
) -> None:
|
|
66
67
|
"""List all snippets in the stash."""
|
|
67
68
|
stash = get_stash_path()
|
|
68
|
-
snippets = sorted(
|
|
69
|
-
p.relative_to(stash).as_posix()
|
|
70
|
-
for p in stash.rglob("*.py")
|
|
71
|
-
if not any(part.startswith(("_", ".")) for part in p.relative_to(stash).parts)
|
|
72
|
-
)
|
|
69
|
+
snippets = sorted(read_index(stash))
|
|
73
70
|
if category:
|
|
74
71
|
snippets = [s for s in snippets if s.startswith(f"{category}/")]
|
|
75
72
|
typer.echo("\n".join(snippets) if snippets else "No snippets found.")
|
|
@@ -82,10 +79,8 @@ def search(
|
|
|
82
79
|
"""Search snippet filenames for a keyword."""
|
|
83
80
|
stash = get_stash_path()
|
|
84
81
|
matches = sorted(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if keyword.lower() in p.name.lower()
|
|
88
|
-
and not any(part.startswith(("_", ".")) for part in p.relative_to(stash).parts)
|
|
82
|
+
s for s in read_index(stash)
|
|
83
|
+
if keyword.lower() in Path(s).name.lower()
|
|
89
84
|
)
|
|
90
85
|
typer.echo("\n".join(matches) if matches else f"No snippets matching '{keyword}'.")
|
|
91
86
|
|
|
@@ -179,6 +174,37 @@ def stash_move(
|
|
|
179
174
|
cfg.save()
|
|
180
175
|
|
|
181
176
|
|
|
177
|
+
@stash_app.command("delete")
|
|
178
|
+
def stash_delete(
|
|
179
|
+
name: str = typer.Argument(..., help="Name of the stash to remove from config."),
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Remove a stash from config (does not delete files on disk)."""
|
|
182
|
+
cfg = SnackConfig()
|
|
183
|
+
if not cfg.has_stash(name):
|
|
184
|
+
typer.echo(
|
|
185
|
+
f"[error] No stash named '{name}'. Run 'snack stash list' to see available stashes.",
|
|
186
|
+
err=True,
|
|
187
|
+
)
|
|
188
|
+
raise typer.Exit(1)
|
|
189
|
+
|
|
190
|
+
was_active = cfg.active_name() == name
|
|
191
|
+
cfg.remove_stash(name)
|
|
192
|
+
|
|
193
|
+
if was_active:
|
|
194
|
+
remaining = cfg.stashes()
|
|
195
|
+
if remaining:
|
|
196
|
+
next_name = next(iter(sorted(remaining)))
|
|
197
|
+
cfg.set_active(next_name)
|
|
198
|
+
cfg.save()
|
|
199
|
+
typer.echo(f"Deleted stash '{name}'. Active stash → '{next_name}'.")
|
|
200
|
+
else:
|
|
201
|
+
cfg.save()
|
|
202
|
+
typer.echo(f"Deleted stash '{name}'. No stashes remaining.")
|
|
203
|
+
else:
|
|
204
|
+
cfg.save()
|
|
205
|
+
typer.echo(f"Deleted stash '{name}'.")
|
|
206
|
+
|
|
207
|
+
|
|
182
208
|
@stash_app.command("add-remote")
|
|
183
209
|
def stash_add_remote(
|
|
184
210
|
repo: str = typer.Argument(..., help="GitHub repo as 'owner/repo' or a full GitHub URL."),
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tarfile
|
|
3
|
+
import tempfile
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
_MANIFEST = ".snack_index"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_index(stash: Path) -> list[str]:
|
|
15
|
+
"""Return all tracked snack paths (relative to stash root)."""
|
|
16
|
+
index = stash / _MANIFEST
|
|
17
|
+
if not index.exists():
|
|
18
|
+
return []
|
|
19
|
+
return [l.strip() for l in index.read_text().splitlines() if l.strip()]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _track(stash: Path, rel_path: str) -> None:
|
|
23
|
+
"""Add a path to the stash manifest if not already present."""
|
|
24
|
+
index = stash / _MANIFEST
|
|
25
|
+
existing = set(read_index(stash))
|
|
26
|
+
if rel_path not in existing:
|
|
27
|
+
with open(index, "a") as f:
|
|
28
|
+
f.write(rel_path + "\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def unpack(stash: Path, snippet_path: str, flat: bool, force: bool) -> None:
|
|
32
|
+
"""Copy a file from the stash into the current working directory."""
|
|
33
|
+
src = stash / snippet_path
|
|
34
|
+
if not src.exists():
|
|
35
|
+
typer.echo(f"[error] '{snippet_path}' not found in stash ({stash}).", err=True)
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
|
|
38
|
+
dest = Path.cwd() / (src.name if flat else snippet_path)
|
|
39
|
+
_copy(src, dest, force)
|
|
40
|
+
typer.echo(f"Unpacked {snippet_path} → {dest}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pack(stash: Path, snippet_path: str, force: bool) -> None:
|
|
44
|
+
"""Copy a file from the current working directory into the stash."""
|
|
45
|
+
src = Path.cwd() / snippet_path
|
|
46
|
+
if not src.exists():
|
|
47
|
+
typer.echo(f"[error] '{snippet_path}' not found in current directory.", err=True)
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
|
|
50
|
+
dest = stash / snippet_path
|
|
51
|
+
_copy(src, dest, force)
|
|
52
|
+
_track(stash, snippet_path)
|
|
53
|
+
typer.echo(f"Packed {snippet_path} → {dest}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def add_remote(stash: Path, repo: str, subdir: Optional[str], force: bool) -> None:
|
|
57
|
+
"""Download .py files from a GitHub repo into the stash."""
|
|
58
|
+
from snacks.auth import get_github_token
|
|
59
|
+
|
|
60
|
+
owner, repo_name = _parse_github_repo(repo)
|
|
61
|
+
url = f"https://api.github.com/repos/{owner}/{repo_name}/tarball"
|
|
62
|
+
headers = {"User-Agent": "python-snacks", "Accept": "application/vnd.github+json"}
|
|
63
|
+
|
|
64
|
+
typer.echo(f"Fetching {owner}/{repo_name}...")
|
|
65
|
+
|
|
66
|
+
def _make_request() -> urllib.request.Request:
|
|
67
|
+
return urllib.request.Request(url, headers=headers)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
_download_and_install(stash, owner, repo_name, subdir, force, _make_request())
|
|
71
|
+
except urllib.error.HTTPError as e:
|
|
72
|
+
if e.code != 404:
|
|
73
|
+
typer.echo(f"[error] HTTP {e.code}: {e.reason}", err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
# 404 may mean private repo — authenticate and retry once
|
|
76
|
+
token = get_github_token()
|
|
77
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
78
|
+
try:
|
|
79
|
+
_download_and_install(stash, owner, repo_name, subdir, force, _make_request())
|
|
80
|
+
except urllib.error.HTTPError as e2:
|
|
81
|
+
typer.echo(f"[error] HTTP {e2.code}: {e2.reason}", err=True)
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
except urllib.error.URLError as e:
|
|
84
|
+
typer.echo(f"[error] Network error: {e.reason}", err=True)
|
|
85
|
+
raise typer.Exit(1)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _download_and_install(
|
|
89
|
+
stash: Path,
|
|
90
|
+
owner: str,
|
|
91
|
+
repo_name: str,
|
|
92
|
+
subdir: Optional[str],
|
|
93
|
+
force: bool,
|
|
94
|
+
req: urllib.request.Request,
|
|
95
|
+
) -> None:
|
|
96
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
97
|
+
tmp = Path(tmpdir)
|
|
98
|
+
tarball = tmp / "repo.tar.gz"
|
|
99
|
+
|
|
100
|
+
with urllib.request.urlopen(req) as response:
|
|
101
|
+
tarball.write_bytes(response.read())
|
|
102
|
+
|
|
103
|
+
extract_dir = tmp / "repo"
|
|
104
|
+
extract_dir.mkdir()
|
|
105
|
+
with tarfile.open(tarball) as tf:
|
|
106
|
+
try:
|
|
107
|
+
tf.extractall(extract_dir, filter="data")
|
|
108
|
+
except TypeError:
|
|
109
|
+
tf.extractall(extract_dir) # Python < 3.12
|
|
110
|
+
|
|
111
|
+
roots = list(extract_dir.iterdir())
|
|
112
|
+
if not roots:
|
|
113
|
+
typer.echo("[error] Downloaded archive was empty.", err=True)
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
repo_root = roots[0]
|
|
116
|
+
|
|
117
|
+
py_files = sorted(
|
|
118
|
+
p for p in repo_root.rglob("*.py")
|
|
119
|
+
if subdir is None or _is_under_subdir(p.relative_to(repo_root), subdir)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if not py_files:
|
|
123
|
+
msg = "No Python files found"
|
|
124
|
+
if subdir:
|
|
125
|
+
msg += f" under '{subdir}'"
|
|
126
|
+
typer.echo(msg + ".")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
for src in py_files:
|
|
130
|
+
rel = src.relative_to(repo_root)
|
|
131
|
+
_copy(src, stash / rel, force)
|
|
132
|
+
_track(stash, rel.as_posix())
|
|
133
|
+
typer.echo(f" + {rel.as_posix()}")
|
|
134
|
+
|
|
135
|
+
typer.echo(f"\nAdded {len(py_files)} file(s) from {owner}/{repo_name}.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_github_repo(repo: str) -> tuple[str, str]:
|
|
139
|
+
repo = repo.strip().rstrip("/")
|
|
140
|
+
for prefix in ("https://github.com/", "http://github.com/", "github.com/"):
|
|
141
|
+
if repo.startswith(prefix):
|
|
142
|
+
repo = repo[len(prefix):]
|
|
143
|
+
break
|
|
144
|
+
if repo.endswith(".git"):
|
|
145
|
+
repo = repo[:-4]
|
|
146
|
+
parts = repo.split("/")
|
|
147
|
+
if len(parts) != 2 or not all(parts):
|
|
148
|
+
typer.echo(
|
|
149
|
+
f"[error] Invalid repo '{repo}'. Use 'owner/repo' or a full GitHub URL.",
|
|
150
|
+
err=True,
|
|
151
|
+
)
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
return parts[0], parts[1]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_under_subdir(rel: Path, subdir: str) -> bool:
|
|
157
|
+
target = tuple(Path(subdir.strip("/")).parts)
|
|
158
|
+
return rel.parts[: len(target)] == target
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _copy(src: Path, dest: Path, force: bool) -> None:
|
|
162
|
+
if dest.exists() and not force:
|
|
163
|
+
overwrite = typer.confirm(f"'{dest}' already exists. Overwrite?")
|
|
164
|
+
if not overwrite:
|
|
165
|
+
typer.echo("Aborted.")
|
|
166
|
+
raise typer.Exit(0)
|
|
167
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
shutil.copy2(src, dest)
|
|
@@ -19,6 +19,9 @@ def stash(tmp_path_factory):
|
|
|
19
19
|
(d / "auth" / "jwt_helpers.py").write_text("# jwt\n")
|
|
20
20
|
(d / "forms").mkdir()
|
|
21
21
|
(d / "forms" / "contact_form.py").write_text("# contact\n")
|
|
22
|
+
(d / ".snack_index").write_text(
|
|
23
|
+
"auth/google_oauth.py\nauth/jwt_helpers.py\nforms/contact_form.py\n"
|
|
24
|
+
)
|
|
22
25
|
return d
|
|
23
26
|
|
|
24
27
|
|
|
@@ -76,6 +79,22 @@ def test_list_empty_category(stash_env):
|
|
|
76
79
|
assert "No snippets found" in result.output
|
|
77
80
|
|
|
78
81
|
|
|
82
|
+
def test_list_excludes_non_snack_files(stash, stash_env):
|
|
83
|
+
"""Files present in the stash directory but not tracked in .snack_index are excluded."""
|
|
84
|
+
# Drop arbitrary .py files directly into the stash (not via pack)
|
|
85
|
+
(stash / "setup.py").write_text("# setup\n")
|
|
86
|
+
(stash / "some_project").mkdir()
|
|
87
|
+
(stash / "some_project" / "main.py").write_text("# project file\n")
|
|
88
|
+
|
|
89
|
+
result = runner.invoke(app, ["list"], env=stash_env)
|
|
90
|
+
assert result.exit_code == 0
|
|
91
|
+
assert "setup.py" not in result.output
|
|
92
|
+
assert "some_project/main.py" not in result.output
|
|
93
|
+
# Tracked snippets still show up
|
|
94
|
+
assert "auth/google_oauth.py" in result.output
|
|
95
|
+
assert "forms/contact_form.py" in result.output
|
|
96
|
+
|
|
97
|
+
|
|
79
98
|
# ---------------------------------------------------------------------------
|
|
80
99
|
# search
|
|
81
100
|
# ---------------------------------------------------------------------------
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Tests for `snack stash *` commands."""
|
|
2
2
|
import io
|
|
3
|
+
import os
|
|
3
4
|
import tarfile
|
|
4
5
|
import tempfile
|
|
5
6
|
from pathlib import Path
|
|
@@ -8,6 +9,8 @@ from unittest.mock import MagicMock, patch
|
|
|
8
9
|
import pytest
|
|
9
10
|
from typer.testing import CliRunner
|
|
10
11
|
|
|
12
|
+
local_only = pytest.mark.skipif(os.environ.get("CI") == "true", reason="local only")
|
|
13
|
+
|
|
11
14
|
import snacks.config as config_module
|
|
12
15
|
from snacks.main import app
|
|
13
16
|
|
|
@@ -140,6 +143,49 @@ def test_stash_move_target_exists_errors(cfg_file, tmp_path):
|
|
|
140
143
|
assert "already exists" in result.output
|
|
141
144
|
|
|
142
145
|
|
|
146
|
+
# ── stash delete ─────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def test_stash_delete_removes_from_config(cfg_file, tmp_path):
|
|
149
|
+
stash_dir = tmp_path / "my-stash"
|
|
150
|
+
runner.invoke(app, ["stash", "create", "default", str(stash_dir)], env={})
|
|
151
|
+
result = runner.invoke(app, ["stash", "delete", "default"], env={})
|
|
152
|
+
assert result.exit_code == 0, result.output
|
|
153
|
+
assert "Deleted stash 'default'" in result.output
|
|
154
|
+
assert "stash.default" not in cfg_file.read_text()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_stash_delete_does_not_remove_files(cfg_file, tmp_path):
|
|
158
|
+
stash_dir = tmp_path / "my-stash"
|
|
159
|
+
runner.invoke(app, ["stash", "create", "default", str(stash_dir)], env={})
|
|
160
|
+
runner.invoke(app, ["stash", "delete", "default"], env={})
|
|
161
|
+
assert stash_dir.exists()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_stash_delete_unknown_name_errors(cfg_file):
|
|
165
|
+
result = runner.invoke(app, ["stash", "delete", "nonexistent"], env={})
|
|
166
|
+
assert result.exit_code != 0
|
|
167
|
+
assert "No stash named" in result.output
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_stash_delete_active_promotes_next(cfg_file, tmp_path):
|
|
171
|
+
a = tmp_path / "stash-a"
|
|
172
|
+
b = tmp_path / "stash-b"
|
|
173
|
+
runner.invoke(app, ["stash", "create", "alpha", str(a)], env={})
|
|
174
|
+
runner.invoke(app, ["stash", "create", "beta", str(b), "--no-activate"], env={})
|
|
175
|
+
result = runner.invoke(app, ["stash", "delete", "alpha"], env={})
|
|
176
|
+
assert result.exit_code == 0
|
|
177
|
+
assert "beta" in result.output
|
|
178
|
+
assert "active = beta" in cfg_file.read_text()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_stash_delete_last_stash_leaves_no_active(cfg_file, tmp_path):
|
|
182
|
+
stash_dir = tmp_path / "my-stash"
|
|
183
|
+
runner.invoke(app, ["stash", "create", "default", str(stash_dir)], env={})
|
|
184
|
+
result = runner.invoke(app, ["stash", "delete", "default"], env={})
|
|
185
|
+
assert result.exit_code == 0
|
|
186
|
+
assert "No stashes remaining" in result.output
|
|
187
|
+
|
|
188
|
+
|
|
143
189
|
# ── stash add-remote ─────────────────────────────────────────────────────────
|
|
144
190
|
|
|
145
191
|
def _make_tarball(files: dict[str, str]) -> bytes:
|
|
@@ -231,8 +277,21 @@ def test_add_remote_invalid_repo_errors(remote_stash_env):
|
|
|
231
277
|
def test_add_remote_http_error(remote_stash_env):
|
|
232
278
|
import urllib.error
|
|
233
279
|
with patch("urllib.request.urlopen", side_effect=urllib.error.HTTPError(
|
|
234
|
-
url="", code=
|
|
280
|
+
url="", code=500, msg="Internal Server Error", hdrs=None, fp=None
|
|
235
281
|
)):
|
|
282
|
+
result = runner.invoke(
|
|
283
|
+
app, ["stash", "add-remote", "owner/repo"], env=remote_stash_env
|
|
284
|
+
)
|
|
285
|
+
assert result.exit_code != 0
|
|
286
|
+
assert "500" in result.output
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_add_remote_not_found_after_auth(remote_stash_env):
|
|
290
|
+
"""Repo not found even after authentication — should report 404."""
|
|
291
|
+
import urllib.error
|
|
292
|
+
not_found = urllib.error.HTTPError(url="", code=404, msg="Not Found", hdrs=None, fp=None)
|
|
293
|
+
with patch("urllib.request.urlopen", side_effect=not_found), \
|
|
294
|
+
patch("snacks.auth._token_from_gh_cli", return_value="ghp_token"):
|
|
236
295
|
result = runner.invoke(
|
|
237
296
|
app, ["stash", "add-remote", "owner/missing-repo"], env=remote_stash_env
|
|
238
297
|
)
|
|
@@ -240,6 +299,49 @@ def test_add_remote_http_error(remote_stash_env):
|
|
|
240
299
|
assert "404" in result.output
|
|
241
300
|
|
|
242
301
|
|
|
302
|
+
def test_add_remote_public_repo_needs_no_auth(remote_stash, remote_stash_env):
|
|
303
|
+
tarball = _make_tarball({"auth/oauth.py": "# oauth\n"})
|
|
304
|
+
mock_response = _mock_urlopen(tarball)
|
|
305
|
+
with patch("urllib.request.urlopen", return_value=mock_response) as mock_open:
|
|
306
|
+
result = runner.invoke(app, ["stash", "add-remote", "owner/repo"], env=remote_stash_env)
|
|
307
|
+
assert result.exit_code == 0
|
|
308
|
+
req = mock_open.call_args[0][0]
|
|
309
|
+
assert req.get_header("Authorization") is None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@local_only
|
|
313
|
+
def test_add_remote_private_repo_retries_with_gh_token(remote_stash, remote_stash_env):
|
|
314
|
+
"""On 404, auth via gh CLI and retry."""
|
|
315
|
+
import urllib.error
|
|
316
|
+
tarball = _make_tarball({"auth/oauth.py": "# oauth\n"})
|
|
317
|
+
not_found = urllib.error.HTTPError(url="", code=404, msg="Not Found", hdrs=None, fp=None)
|
|
318
|
+
auth_response = _mock_urlopen(tarball)
|
|
319
|
+
|
|
320
|
+
with patch("urllib.request.urlopen", side_effect=[not_found, auth_response]), \
|
|
321
|
+
patch("snacks.auth._token_from_gh_cli", return_value="ghp_cli_token"):
|
|
322
|
+
result = runner.invoke(app, ["stash", "add-remote", "owner/private-repo"], env=remote_stash_env)
|
|
323
|
+
|
|
324
|
+
assert result.exit_code == 0
|
|
325
|
+
assert (remote_stash / "auth" / "oauth.py").exists()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@local_only
|
|
329
|
+
def test_add_remote_private_repo_uses_github_token_env(remote_stash, remote_stash_env):
|
|
330
|
+
"""GITHUB_TOKEN env var is used without prompting."""
|
|
331
|
+
import urllib.error
|
|
332
|
+
tarball = _make_tarball({"auth/oauth.py": "# oauth\n"})
|
|
333
|
+
not_found = urllib.error.HTTPError(url="", code=404, msg="Not Found", hdrs=None, fp=None)
|
|
334
|
+
auth_response = _mock_urlopen(tarball)
|
|
335
|
+
|
|
336
|
+
env = {**remote_stash_env, "GITHUB_TOKEN": "ghp_envtoken"}
|
|
337
|
+
with patch("urllib.request.urlopen", side_effect=[not_found, auth_response]) as mock_open:
|
|
338
|
+
result = runner.invoke(app, ["stash", "add-remote", "owner/private-repo"], env=env)
|
|
339
|
+
|
|
340
|
+
assert result.exit_code == 0
|
|
341
|
+
final_req = mock_open.call_args[0][0]
|
|
342
|
+
assert final_req.get_header("Authorization") == "Bearer ghp_envtoken"
|
|
343
|
+
|
|
344
|
+
|
|
243
345
|
def test_add_remote_no_py_files(remote_stash, remote_stash_env):
|
|
244
346
|
tarball = _make_tarball({"README.md": "# readme"})
|
|
245
347
|
mock_response = _mock_urlopen(tarball)
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import shutil
|
|
2
|
-
import tarfile
|
|
3
|
-
import tempfile
|
|
4
|
-
import urllib.error
|
|
5
|
-
import urllib.request
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Optional
|
|
8
|
-
|
|
9
|
-
import typer
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def unpack(stash: Path, snippet_path: str, flat: bool, force: bool) -> None:
|
|
13
|
-
"""Copy a file from the stash into the current working directory."""
|
|
14
|
-
src = stash / snippet_path
|
|
15
|
-
if not src.exists():
|
|
16
|
-
typer.echo(f"[error] '{snippet_path}' not found in stash ({stash}).", err=True)
|
|
17
|
-
raise typer.Exit(1)
|
|
18
|
-
|
|
19
|
-
dest = Path.cwd() / (src.name if flat else snippet_path)
|
|
20
|
-
_copy(src, dest, force)
|
|
21
|
-
typer.echo(f"Unpacked {snippet_path} → {dest}")
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def pack(stash: Path, snippet_path: str, force: bool) -> None:
|
|
25
|
-
"""Copy a file from the current working directory into the stash."""
|
|
26
|
-
src = Path.cwd() / snippet_path
|
|
27
|
-
if not src.exists():
|
|
28
|
-
typer.echo(f"[error] '{snippet_path}' not found in current directory.", err=True)
|
|
29
|
-
raise typer.Exit(1)
|
|
30
|
-
|
|
31
|
-
dest = stash / snippet_path
|
|
32
|
-
_copy(src, dest, force)
|
|
33
|
-
typer.echo(f"Packed {snippet_path} → {dest}")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def add_remote(stash: Path, repo: str, subdir: Optional[str], force: bool) -> None:
|
|
37
|
-
"""Download .py files from a GitHub repo into the stash."""
|
|
38
|
-
owner, repo_name = _parse_github_repo(repo)
|
|
39
|
-
url = f"https://api.github.com/repos/{owner}/{repo_name}/tarball"
|
|
40
|
-
|
|
41
|
-
typer.echo(f"Fetching {owner}/{repo_name}...")
|
|
42
|
-
req = urllib.request.Request(
|
|
43
|
-
url,
|
|
44
|
-
headers={"User-Agent": "snack-stash", "Accept": "application/vnd.github+json"},
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
try:
|
|
48
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
49
|
-
tmp = Path(tmpdir)
|
|
50
|
-
tarball = tmp / "repo.tar.gz"
|
|
51
|
-
|
|
52
|
-
with urllib.request.urlopen(req) as response:
|
|
53
|
-
tarball.write_bytes(response.read())
|
|
54
|
-
|
|
55
|
-
extract_dir = tmp / "repo"
|
|
56
|
-
extract_dir.mkdir()
|
|
57
|
-
with tarfile.open(tarball) as tf:
|
|
58
|
-
try:
|
|
59
|
-
tf.extractall(extract_dir, filter="data")
|
|
60
|
-
except TypeError:
|
|
61
|
-
tf.extractall(extract_dir) # Python < 3.12
|
|
62
|
-
|
|
63
|
-
roots = list(extract_dir.iterdir())
|
|
64
|
-
if not roots:
|
|
65
|
-
typer.echo("[error] Downloaded archive was empty.", err=True)
|
|
66
|
-
raise typer.Exit(1)
|
|
67
|
-
repo_root = roots[0]
|
|
68
|
-
|
|
69
|
-
py_files = sorted(
|
|
70
|
-
p for p in repo_root.rglob("*.py")
|
|
71
|
-
if subdir is None or _is_under_subdir(p.relative_to(repo_root), subdir)
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
if not py_files:
|
|
75
|
-
msg = "No Python files found"
|
|
76
|
-
if subdir:
|
|
77
|
-
msg += f" under '{subdir}'"
|
|
78
|
-
typer.echo(msg + ".")
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
for src in py_files:
|
|
82
|
-
rel = src.relative_to(repo_root)
|
|
83
|
-
_copy(src, stash / rel, force)
|
|
84
|
-
typer.echo(f" + {rel.as_posix()}")
|
|
85
|
-
|
|
86
|
-
typer.echo(f"\nAdded {len(py_files)} file(s) from {owner}/{repo_name}.")
|
|
87
|
-
|
|
88
|
-
except urllib.error.HTTPError as e:
|
|
89
|
-
typer.echo(f"[error] HTTP {e.code}: {e.reason}", err=True)
|
|
90
|
-
raise typer.Exit(1)
|
|
91
|
-
except urllib.error.URLError as e:
|
|
92
|
-
typer.echo(f"[error] Network error: {e.reason}", err=True)
|
|
93
|
-
raise typer.Exit(1)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _parse_github_repo(repo: str) -> tuple[str, str]:
|
|
97
|
-
repo = repo.strip().rstrip("/")
|
|
98
|
-
for prefix in ("https://github.com/", "http://github.com/", "github.com/"):
|
|
99
|
-
if repo.startswith(prefix):
|
|
100
|
-
repo = repo[len(prefix):]
|
|
101
|
-
break
|
|
102
|
-
if repo.endswith(".git"):
|
|
103
|
-
repo = repo[:-4]
|
|
104
|
-
parts = repo.split("/")
|
|
105
|
-
if len(parts) != 2 or not all(parts):
|
|
106
|
-
typer.echo(
|
|
107
|
-
f"[error] Invalid repo '{repo}'. Use 'owner/repo' or a full GitHub URL.",
|
|
108
|
-
err=True,
|
|
109
|
-
)
|
|
110
|
-
raise typer.Exit(1)
|
|
111
|
-
return parts[0], parts[1]
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _is_under_subdir(rel: Path, subdir: str) -> bool:
|
|
115
|
-
target = tuple(Path(subdir.strip("/")).parts)
|
|
116
|
-
return rel.parts[: len(target)] == target
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def _copy(src: Path, dest: Path, force: bool) -> None:
|
|
120
|
-
if dest.exists() and not force:
|
|
121
|
-
overwrite = typer.confirm(f"'{dest}' already exists. Overwrite?")
|
|
122
|
-
if not overwrite:
|
|
123
|
-
typer.echo("Aborted.")
|
|
124
|
-
raise typer.Exit(0)
|
|
125
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
-
shutil.copy2(src, dest)
|
|
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
|