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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-snacks
3
- Version: 0.1.4
3
+ Version: 0.2.2
4
4
  Summary: A CLI tool for managing a personal stash of reusable Python code snippets.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-snacks"
7
- version = "0.1.4"
7
+ version = "0.2.2"
8
8
  description = "A CLI tool for managing a personal stash of reusable Python code snippets."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-snacks
3
- Version: 0.1.4
3
+ Version: 0.2.2
4
4
  Summary: A CLI tool for managing a personal stash of reusable Python code snippets.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -8,6 +8,7 @@ python_snacks.egg-info/entry_points.txt
8
8
  python_snacks.egg-info/requires.txt
9
9
  python_snacks.egg-info/top_level.txt
10
10
  snacks/__init__.py
11
+ snacks/auth.py
11
12
  snacks/config.py
12
13
  snacks/main.py
13
14
  snacks/ops.py
@@ -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
- p.relative_to(stash).as_posix()
86
- for p in stash.rglob("*.py")
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=404, msg="Not Found", hdrs=None, fp=None
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