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.
- python_snacks-0.1.2.dist-info/METADATA +230 -0
- python_snacks-0.1.2.dist-info/RECORD +10 -0
- python_snacks-0.1.2.dist-info/WHEEL +5 -0
- python_snacks-0.1.2.dist-info/entry_points.txt +2 -0
- python_snacks-0.1.2.dist-info/licenses/LICENSE +21 -0
- python_snacks-0.1.2.dist-info/top_level.txt +1 -0
- snacks/__init__.py +0 -0
- snacks/config.py +149 -0
- snacks/main.py +188 -0
- snacks/ops.py +126 -0
|
@@ -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,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)
|