python-snacks 0.1.2__py3-none-any.whl

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.
@@ -0,0 +1,230 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-snacks
3
+ Version: 0.1.2
4
+ Summary: A CLI tool for managing a personal stash of reusable Python code snippets.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: typer[all]>=0.9
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest>=8; extra == "test"
11
+ Dynamic: license-file
12
+
13
+ # Snack Stash
14
+
15
+ A personal CLI tool for managing a local stash of reusable Python code snippets. Browse, copy, and curate snippets across projects with a single command.
16
+
17
+ ```bash
18
+ pipx install snack-stash
19
+ snack stash create default ~/snack-stash
20
+ snack unpack auth/google_oauth.py
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ **Recommended (pipx):**
28
+ ```bash
29
+ pipx install snack-stash
30
+ ```
31
+
32
+ **Alternative (pip):**
33
+ ```bash
34
+ pip install snack-stash
35
+ ```
36
+
37
+ Requires Python 3.10+.
38
+
39
+ ---
40
+
41
+ ## Configuration
42
+
43
+ ### First-time setup
44
+
45
+ Create a stash directory and register it:
46
+
47
+ ```bash
48
+ snack stash create default ~/snack-stash
49
+ ```
50
+
51
+ This creates `~/snack-stash`, writes `~/.snackstashrc`, and sets `default` as the active stash.
52
+
53
+ ### Environment variable (overrides config file)
54
+
55
+ ```bash
56
+ export SNACK_STASH=~/snack-stash
57
+ ```
58
+
59
+ Add to `~/.zshrc` or `~/.bashrc` to make it permanent. Takes priority over everything else.
60
+
61
+ ### Config file (`~/.snackstashrc`)
62
+
63
+ Created automatically by `snack stash create`. You can also edit it by hand:
64
+
65
+ ```ini
66
+ [config]
67
+ active = default
68
+
69
+ [stash.default]
70
+ path = ~/snack-stash
71
+
72
+ [stash.work]
73
+ path = ~/work-stash
74
+ ```
75
+
76
+ **Priority order:** `SNACK_STASH` env var → `~/.snackstashrc` active stash → error.
77
+
78
+ ---
79
+
80
+ ## Stash structure
81
+
82
+ A stash is a plain directory of `.py` files, organized into subdirectories by category:
83
+
84
+ ```
85
+ ~/snack-stash/
86
+ ├── auth/
87
+ │ ├── google_oauth_fastapi.py
88
+ │ ├── google_oauth_flask.py
89
+ │ └── jwt_helpers.py
90
+ ├── forms/
91
+ │ ├── contact_form.py
92
+ │ └── newsletter_signup.py
93
+ └── email/
94
+ └── smtp_sender.py
95
+ ```
96
+
97
+ You can manage the directory with Git, Dropbox, or any sync tool independently of this CLI.
98
+
99
+ ---
100
+
101
+ ## Commands
102
+
103
+ ### `snack list [category]`
104
+
105
+ List all snippets in the active stash.
106
+
107
+ ```bash
108
+ snack list # all snippets
109
+ snack list auth # filtered by category (subdirectory)
110
+ ```
111
+
112
+ ### `snack search <keyword>`
113
+
114
+ Search snippet filenames for a keyword (case-insensitive).
115
+
116
+ ```bash
117
+ snack search oauth
118
+ # auth/google_oauth_fastapi.py
119
+ # auth/google_oauth_flask.py
120
+ ```
121
+
122
+ ### `snack unpack <snippet>`
123
+
124
+ Copy a snippet **from the stash** into the current working directory.
125
+
126
+ ```bash
127
+ snack unpack auth/google_oauth_fastapi.py
128
+ # → ./auth/google_oauth_fastapi.py
129
+
130
+ snack unpack auth/google_oauth_fastapi.py --flat
131
+ # → ./google_oauth_fastapi.py (no subdirectory)
132
+
133
+ snack unpack auth/google_oauth_fastapi.py --force
134
+ # Overwrites without prompting
135
+ ```
136
+
137
+ ### `snack pack <snippet>`
138
+
139
+ Copy a file **from the current directory** back into the stash. Use this when you've improved a snippet on a project and want to update the canonical version.
140
+
141
+ ```bash
142
+ snack pack auth/google_oauth_fastapi.py
143
+ snack pack auth/google_oauth_fastapi.py --force
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Stash management
149
+
150
+ ### `snack stash create <name> <path>`
151
+
152
+ Register a new named stash. Creates the directory if it doesn't exist.
153
+
154
+ ```bash
155
+ snack stash create default ~/snack-stash
156
+ snack stash create work ~/work-stash --no-activate
157
+ ```
158
+
159
+ The first stash created is automatically set as active. Use `--no-activate` to add a stash without switching to it.
160
+
161
+ ### `snack stash list`
162
+
163
+ Show all configured stashes and which one is active.
164
+
165
+ ```bash
166
+ snack stash list
167
+ # default /Users/you/snack-stash ← active
168
+ # work /Users/you/work-stash
169
+ ```
170
+
171
+ ### `snack stash move <name> <new-path>`
172
+
173
+ Move a stash to a new location. Moves the directory on disk and updates the config.
174
+
175
+ ```bash
176
+ snack stash move default ~/new-location/snack-stash
177
+ ```
178
+
179
+ ### `snack stash add-remote <repo>`
180
+
181
+ Copy Python snippets from a public GitHub repository into the active stash. Downloads the repo as a tarball and copies all `.py` files, preserving directory structure.
182
+
183
+ ```bash
184
+ snack stash add-remote owner/repo
185
+ snack stash add-remote https://github.com/owner/repo
186
+ snack stash add-remote owner/repo --subdir auth # only copy files under auth/
187
+ snack stash add-remote owner/repo --force # overwrite without prompting
188
+ ```
189
+
190
+ ---
191
+
192
+ ## Error handling
193
+
194
+ | Situation | Behaviour |
195
+ |---|---|
196
+ | No stash configured | Error with setup instructions |
197
+ | Stash path doesn't exist | Error with the attempted path |
198
+ | Snippet not found in stash | Error — use `snack list` or `snack search` |
199
+ | Source file not found | Error |
200
+ | Destination file already exists | Prompt to confirm, or skip with `--force` |
201
+ | Named stash already exists | Error |
202
+ | Move target already exists | Error |
203
+ | GitHub repo not found (HTTP 404) | Error with status code |
204
+
205
+ ---
206
+
207
+ ## Project structure
208
+
209
+ ```
210
+ python-snacks/
211
+ ├── pyproject.toml
212
+ ├── snacks/
213
+ │ ├── main.py # Typer app, all command definitions
214
+ │ ├── config.py # SnackConfig class, stash path resolution
215
+ │ └── ops.py # File copy logic (pack, unpack, add_remote)
216
+ └── tests/
217
+ ├── test_commands.py # snippet commands
218
+ └── test_stash_commands.py # stash management commands
219
+ ```
220
+
221
+ ---
222
+
223
+ ## CI / CD
224
+
225
+ - **CI:** Tests run on every push and PR to `main` across Python 3.10, 3.11, and 3.12.
226
+ - **Publish:** Push a `v*` tag to trigger a build, PyPI publish, and GitHub Release.
227
+
228
+ ```bash
229
+ git tag v0.2.0 && git push origin v0.2.0
230
+ ```
@@ -0,0 +1,10 @@
1
+ python_snacks-0.1.2.dist-info/licenses/LICENSE,sha256=htWWuqer0G2fpR7CYy3QZtv5TnWL5dqtSSbwYXxSaD4,1064
2
+ snacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ snacks/config.py,sha256=M7NiX2PO6rvCyehs50FWdJr-aBlW6W7UNWmulOAJeP4,4647
4
+ snacks/main.py,sha256=cowFc93HT5VzBlgI-u8bDeI3Mvwf2OyIAm4UACgWO8U,6692
5
+ snacks/ops.py,sha256=gKPd_z-W-l5XkpccwyMfEmfCzZQMNl9Pf5gVHRkaIvI,4294
6
+ python_snacks-0.1.2.dist-info/METADATA,sha256=usF76ELo6ui-k8zZL1KSUvV6hQj_YkULqX0V_AQzTjA,5475
7
+ python_snacks-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ python_snacks-0.1.2.dist-info/entry_points.txt,sha256=Lh3nJdMRCmTzRS8GQh1jM5wHu78UEt3Y5-3mxYSrZeM,42
9
+ python_snacks-0.1.2.dist-info/top_level.txt,sha256=-WmY1X8EWrFI_5VT9DpsWVe3xv30-2TUXRQpTS_zde8,7
10
+ python_snacks-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ snack = snacks.main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kicka5h
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.
@@ -0,0 +1 @@
1
+ snacks
snacks/__init__.py ADDED
File without changes
snacks/config.py ADDED
@@ -0,0 +1,149 @@
1
+ import configparser
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ CONFIG_PATH = Path("~/.snackstashrc").expanduser()
9
+
10
+
11
+ class SnackConfig:
12
+ """Read/write ~/.snackstashrc.
13
+
14
+ New format (INI):
15
+ [config]
16
+ active = default
17
+
18
+ [stash.default]
19
+ path = ~/snack-stash
20
+
21
+ Legacy format (still read, never written):
22
+ stash=~/snack-stash
23
+ """
24
+
25
+ def __init__(self) -> None:
26
+ self._cp = configparser.ConfigParser()
27
+ self._legacy_path: Optional[Path] = None
28
+
29
+ if not CONFIG_PATH.exists():
30
+ return
31
+
32
+ content = CONFIG_PATH.read_text().strip()
33
+ if not content:
34
+ return
35
+
36
+ if not content.lstrip().startswith("["):
37
+ # Legacy sectionless format
38
+ for line in content.splitlines():
39
+ line = line.strip()
40
+ if line.startswith("stash="):
41
+ self._legacy_path = Path(line[len("stash="):].strip()).expanduser()
42
+ break
43
+ else:
44
+ self._cp.read_string(content)
45
+
46
+ # ── read ────────────────────────────────────────────────────────────────
47
+
48
+ @property
49
+ def is_legacy(self) -> bool:
50
+ return self._legacy_path is not None
51
+
52
+ @property
53
+ def legacy_path(self) -> Optional[Path]:
54
+ return self._legacy_path
55
+
56
+ def active_name(self) -> Optional[str]:
57
+ return self._cp.get("config", "active", fallback=None)
58
+
59
+ def stashes(self) -> dict[str, Path]:
60
+ return {
61
+ section[len("stash."):]: Path(self._cp[section]["path"]).expanduser()
62
+ for section in self._cp.sections()
63
+ if section.startswith("stash.")
64
+ }
65
+
66
+ def stash_path(self, name: str) -> Optional[Path]:
67
+ section = f"stash.{name}"
68
+ if section in self._cp and "path" in self._cp[section]:
69
+ return Path(self._cp[section]["path"]).expanduser()
70
+ return None
71
+
72
+ def has_stash(self, name: str) -> bool:
73
+ return f"stash.{name}" in self._cp
74
+
75
+ # ── write ───────────────────────────────────────────────────────────────
76
+
77
+ def set_stash(self, name: str, path: Path) -> None:
78
+ if "config" not in self._cp:
79
+ self._cp["config"] = {}
80
+ self._cp[f"stash.{name}"] = {"path": str(path)}
81
+
82
+ def set_active(self, name: str) -> None:
83
+ if "config" not in self._cp:
84
+ self._cp["config"] = {}
85
+ self._cp["config"]["active"] = name
86
+
87
+ def remove_stash(self, name: str) -> bool:
88
+ return self._cp.remove_section(f"stash.{name}")
89
+
90
+ def save(self) -> None:
91
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
92
+ with open(CONFIG_PATH, "w") as f:
93
+ self._cp.write(f)
94
+
95
+
96
+ def get_stash_path() -> Path:
97
+ """Resolve the active stash directory. Raises Exit(1) on any error."""
98
+ # Env var overrides everything
99
+ env_val = os.environ.get("SNACK_STASH")
100
+ if env_val:
101
+ path = Path(env_val).expanduser()
102
+ if not path.exists():
103
+ typer.echo(
104
+ f"[error] SNACK_STASH is set to '{env_val}' but that path does not exist.",
105
+ err=True,
106
+ )
107
+ raise typer.Exit(1)
108
+ return path
109
+
110
+ cfg = SnackConfig()
111
+
112
+ # Legacy format
113
+ if cfg.is_legacy:
114
+ path = cfg.legacy_path
115
+ if not path.exists():
116
+ typer.echo(
117
+ f"[error] stash path in ~/.snackstashrc ('{path}') does not exist.",
118
+ err=True,
119
+ )
120
+ raise typer.Exit(1)
121
+ return path
122
+
123
+ # Multi-stash: use active
124
+ active = cfg.active_name()
125
+ if active:
126
+ path = cfg.stash_path(active)
127
+ if path is None:
128
+ typer.echo(
129
+ f"[error] Active stash '{active}' is not defined in config.",
130
+ err=True,
131
+ )
132
+ raise typer.Exit(1)
133
+ if not path.exists():
134
+ typer.echo(
135
+ f"[error] Active stash '{active}' path '{path}' does not exist.",
136
+ err=True,
137
+ )
138
+ raise typer.Exit(1)
139
+ return path
140
+
141
+ typer.echo(
142
+ "[error] Snack stash location is not configured.\n"
143
+ "Create a stash with:\n"
144
+ " snack stash create default ~/snack-stash\n"
145
+ "Or set the SNACK_STASH environment variable:\n"
146
+ " export SNACK_STASH=~/snack-stash",
147
+ err=True,
148
+ )
149
+ raise typer.Exit(1)
snacks/main.py ADDED
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ import os
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from snacks.config import SnackConfig, get_stash_path
12
+ from snacks.ops import add_remote as do_add_remote
13
+ from snacks.ops import pack as do_pack, unpack as do_unpack
14
+
15
+ app = typer.Typer(
16
+ name="snack",
17
+ help="Manage your personal snack stash of reusable Python snippets.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+
22
+ def _version_callback(value: bool) -> None:
23
+ if value:
24
+ version = importlib.metadata.version("python-snacks")
25
+ typer.echo(f"snack-stash v{version}")
26
+ raise typer.Exit()
27
+
28
+
29
+ @app.callback()
30
+ def _main(
31
+ version: Optional[bool] = typer.Option(
32
+ None, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
33
+ ),
34
+ ) -> None:
35
+ pass
36
+
37
+ stash_app = typer.Typer(help="Manage stash directories.")
38
+ app.add_typer(stash_app, name="stash")
39
+
40
+
41
+ # ── snippet commands ─────────────────────────────────────────────────────────
42
+
43
+ @app.command()
44
+ def unpack(
45
+ snippet: str = typer.Argument(..., help="Path relative to the stash root (e.g. auth/google_oauth.py)"),
46
+ flat: bool = typer.Option(False, "--flat", help="Copy file into cwd without preserving subdirectory structure."),
47
+ force: bool = typer.Option(False, "--force", help="Overwrite existing files without prompting."),
48
+ ) -> None:
49
+ """Copy a snippet FROM the stash INTO the current working directory."""
50
+ do_unpack(get_stash_path(), snippet, flat=flat, force=force)
51
+
52
+
53
+ @app.command()
54
+ def pack(
55
+ snippet: str = typer.Argument(..., help="Path relative to cwd (e.g. auth/google_oauth.py)"),
56
+ force: bool = typer.Option(False, "--force", help="Overwrite existing stash file without prompting."),
57
+ ) -> None:
58
+ """Copy a snippet FROM the current working directory INTO the stash."""
59
+ do_pack(get_stash_path(), snippet, force=force)
60
+
61
+
62
+ @app.command(name="list")
63
+ def list_snacks(
64
+ category: Optional[str] = typer.Argument(None, help="Filter by category (subdirectory name)."),
65
+ ) -> None:
66
+ """List all snippets in the stash."""
67
+ stash = get_stash_path()
68
+ snippets = sorted(
69
+ p.relative_to(stash).as_posix()
70
+ for p in stash.rglob("*.py")
71
+ if not p.name.startswith("_")
72
+ )
73
+ if category:
74
+ snippets = [s for s in snippets if s.startswith(f"{category}/")]
75
+ typer.echo("\n".join(snippets) if snippets else "No snippets found.")
76
+
77
+
78
+ @app.command()
79
+ def search(
80
+ keyword: str = typer.Argument(..., help="Keyword to search for in snippet filenames."),
81
+ ) -> None:
82
+ """Search snippet filenames for a keyword."""
83
+ stash = get_stash_path()
84
+ matches = sorted(
85
+ p.relative_to(stash).as_posix()
86
+ for p in stash.rglob("*.py")
87
+ if keyword.lower() in p.name.lower() and not p.name.startswith("_")
88
+ )
89
+ typer.echo("\n".join(matches) if matches else f"No snippets matching '{keyword}'.")
90
+
91
+
92
+ # ── stash management commands ────────────────────────────────────────────────
93
+
94
+ @stash_app.command("create")
95
+ def stash_create(
96
+ name: str = typer.Argument(..., help="Name for the new stash (e.g. 'default', 'work')."),
97
+ path: Path = typer.Argument(..., help="Directory path for the new stash."),
98
+ activate: bool = typer.Option(True, "--activate/--no-activate", help="Set as the active stash."),
99
+ ) -> None:
100
+ """Create a new stash directory and register it in config."""
101
+ cfg = SnackConfig()
102
+ if cfg.has_stash(name):
103
+ typer.echo(f"[error] A stash named '{name}' already exists.", err=True)
104
+ raise typer.Exit(1)
105
+
106
+ is_first = len(cfg.stashes()) == 0
107
+ expanded = path.expanduser().resolve()
108
+ expanded.mkdir(parents=True, exist_ok=True)
109
+ cfg.set_stash(name, expanded)
110
+
111
+ if activate or is_first:
112
+ cfg.set_active(name)
113
+
114
+ cfg.save()
115
+ typer.echo(f"Created stash '{name}' at {expanded}.")
116
+ if activate or is_first:
117
+ typer.echo(f"Active stash → '{name}'.")
118
+
119
+
120
+ @stash_app.command("list")
121
+ def stash_list() -> None:
122
+ """List all configured stashes."""
123
+ cfg = SnackConfig()
124
+ env_val = os.environ.get("SNACK_STASH")
125
+
126
+ if cfg.is_legacy:
127
+ typer.echo(f" (legacy) {cfg.legacy_path} ← active")
128
+ return
129
+
130
+ stashes = cfg.stashes()
131
+ if not stashes:
132
+ typer.echo(
133
+ "No stashes configured. Create one with:\n"
134
+ " snack stash create <name> <path>"
135
+ )
136
+ return
137
+
138
+ active = cfg.active_name()
139
+ width = max(len(n) for n in stashes)
140
+ for name, stash_path in sorted(stashes.items()):
141
+ marker = " ← active" if name == active else ""
142
+ missing = " [path missing!]" if not stash_path.exists() else ""
143
+ typer.echo(f" {name:<{width}} {stash_path}{marker}{missing}")
144
+
145
+ if env_val:
146
+ typer.echo(f"\n Note: SNACK_STASH env var is set and overrides the active stash.")
147
+
148
+
149
+ @stash_app.command("move")
150
+ def stash_move(
151
+ name: str = typer.Argument(..., help="Name of the stash to move."),
152
+ new_path: Path = typer.Argument(..., help="New directory path."),
153
+ ) -> None:
154
+ """Move a stash to a new location and update config."""
155
+ cfg = SnackConfig()
156
+ if not cfg.has_stash(name):
157
+ typer.echo(
158
+ f"[error] No stash named '{name}'. Run 'snack stash list' to see available stashes.",
159
+ err=True,
160
+ )
161
+ raise typer.Exit(1)
162
+
163
+ old_path = cfg.stash_path(name)
164
+ expanded_new = new_path.expanduser().resolve()
165
+
166
+ if expanded_new.exists() and expanded_new != old_path:
167
+ typer.echo(f"[error] '{expanded_new}' already exists.", err=True)
168
+ raise typer.Exit(1)
169
+
170
+ if old_path.exists():
171
+ shutil.move(str(old_path), str(expanded_new))
172
+ typer.echo(f"Moved '{name}': {old_path} → {expanded_new}")
173
+ else:
174
+ expanded_new.mkdir(parents=True, exist_ok=True)
175
+ typer.echo(f"Old path {old_path} did not exist. Created {expanded_new}.")
176
+
177
+ cfg.set_stash(name, expanded_new)
178
+ cfg.save()
179
+
180
+
181
+ @stash_app.command("add-remote")
182
+ def stash_add_remote(
183
+ repo: str = typer.Argument(..., help="GitHub repo as 'owner/repo' or a full GitHub URL."),
184
+ subdir: Optional[str] = typer.Option(None, "--subdir", help="Only copy files from this subdirectory of the repo."),
185
+ force: bool = typer.Option(False, "--force", help="Overwrite existing files without prompting."),
186
+ ) -> None:
187
+ """Copy Python snippets from a GitHub repository into the active stash."""
188
+ do_add_remote(get_stash_path(), repo, subdir=subdir, force=force)
snacks/ops.py ADDED
@@ -0,0 +1,126 @@
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)