safekeep 0.2.0__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.
- safekeep/__init__.py +6 -0
- safekeep/cli.py +272 -0
- safekeep/config.py +266 -0
- safekeep/copier.py +333 -0
- safekeep/daemon.py +692 -0
- safekeep/matcher.py +161 -0
- safekeep/volumes.py +28 -0
- safekeep-0.2.0.dist-info/METADATA +180 -0
- safekeep-0.2.0.dist-info/RECORD +13 -0
- safekeep-0.2.0.dist-info/WHEEL +5 -0
- safekeep-0.2.0.dist-info/entry_points.txt +2 -0
- safekeep-0.2.0.dist-info/licenses/LICENSE +21 -0
- safekeep-0.2.0.dist-info/top_level.txt +1 -0
safekeep/matcher.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Matcher gitignore-like con semantica last-match-wins (SPEC.md §4.3 e §5).
|
|
2
|
+
|
|
3
|
+
Ordine di priorità dei layer (dal basso all'alto): builtin → globali →
|
|
4
|
+
defaults del progetto → regole del `.sync`. L'ultima regola che matcha il
|
|
5
|
+
path (o un suo genitore directory) decide il verdict; nessun match →
|
|
6
|
+
semantica allow-list: file NON copiato, directory attraversata.
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def strip_comment(line):
|
|
12
|
+
"""Rimuove i commenti: `# ...` a inizio riga (escape `\\#`) o ` # ...` inline."""
|
|
13
|
+
if line.lstrip().startswith('#'):
|
|
14
|
+
return ''
|
|
15
|
+
return re.split(r'\s#', line, maxsplit=1)[0]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _charclass(pat, i):
|
|
19
|
+
"""`[abc]`/`[!abc]` a pat[i] → (regex, indice dopo) o None se malformed."""
|
|
20
|
+
j = i + 1
|
|
21
|
+
neg = False
|
|
22
|
+
if j < len(pat) and pat[j] in '!^':
|
|
23
|
+
neg = True
|
|
24
|
+
j += 1
|
|
25
|
+
if j < len(pat) and pat[j] == ']': # ']' come primo carattere è letterale
|
|
26
|
+
j += 1
|
|
27
|
+
while j < len(pat) and pat[j] != ']':
|
|
28
|
+
j += 1
|
|
29
|
+
if j >= len(pat):
|
|
30
|
+
return None
|
|
31
|
+
body = re.sub(r'([\\\[\]])', r'\\\1', pat[i + 1:j])
|
|
32
|
+
return ('[^' + body + ']' if neg else '[' + body + ']'), j + 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _glob(pat):
|
|
36
|
+
"""Pattern gitignore → corpo di regex (ancorata dal chiamante)."""
|
|
37
|
+
out = []
|
|
38
|
+
i, n = 0, len(pat)
|
|
39
|
+
while i < n:
|
|
40
|
+
if pat[i] == '\\' and i + 1 < n: # \x escape generico
|
|
41
|
+
out.append(re.escape(pat[i + 1]))
|
|
42
|
+
i += 2
|
|
43
|
+
elif pat[i:i + 2] == '**':
|
|
44
|
+
if pat[i:i + 3] == '**/': # a/**/b → segmenti multipli opzionali
|
|
45
|
+
out.append('(?:[^/]+/)*')
|
|
46
|
+
i += 3
|
|
47
|
+
else:
|
|
48
|
+
out.append('.*') # ** → qualsiasi segmenti (include /)
|
|
49
|
+
i += 2
|
|
50
|
+
elif pat[i] == '*': # * entro un singolo segmento
|
|
51
|
+
out.append('[^/]*')
|
|
52
|
+
i += 1
|
|
53
|
+
elif pat[i] == '?':
|
|
54
|
+
out.append('[^/]')
|
|
55
|
+
i += 1
|
|
56
|
+
elif pat[i] == '[':
|
|
57
|
+
cls = _charclass(pat, i)
|
|
58
|
+
if cls is None:
|
|
59
|
+
out.append('\\[')
|
|
60
|
+
i += 1
|
|
61
|
+
else:
|
|
62
|
+
out.append(cls[0])
|
|
63
|
+
i = cls[1]
|
|
64
|
+
else:
|
|
65
|
+
out.append(re.escape(pat[i]))
|
|
66
|
+
i += 1
|
|
67
|
+
return ''.join(out)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Rule:
|
|
71
|
+
"""Singola regola `include:`/`exclude:` con pattern gitignore-like."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, pattern, include):
|
|
74
|
+
if '\0' in pattern:
|
|
75
|
+
# un pattern con NUL arriva da un `.sync` non attendibile e finirebbe
|
|
76
|
+
# in argv di fswatch → ValueError "embedded null byte" (CR-03)
|
|
77
|
+
raise ValueError(f'pattern contiene un carattere NUL: {pattern!r}')
|
|
78
|
+
self.pattern = pattern
|
|
79
|
+
self.include = include
|
|
80
|
+
self.dir_only = pattern.endswith('/') # foo/ → solo directory
|
|
81
|
+
p = pattern[:-1] if self.dir_only else pattern
|
|
82
|
+
self.anchored = '/' in p # /foo o slash interna → alla root
|
|
83
|
+
if p.startswith('/'):
|
|
84
|
+
p = p[1:]
|
|
85
|
+
# dir/** matcha anche dir stessa, così il pruning la esclude per intero
|
|
86
|
+
body = _glob(p[:-3]) + '(?:/.*)?' if p.endswith('/**') else _glob(p)
|
|
87
|
+
try:
|
|
88
|
+
self._re = re.compile('(?:' + body + r')\Z')
|
|
89
|
+
except re.error as e:
|
|
90
|
+
# `re.error` NON è un ValueError: un pattern malevolo/esotico
|
|
91
|
+
# (es. `[z-a]*`) va convertito così il parser lo gestisce come
|
|
92
|
+
# riga invalida invece di far crashare il daemon (CR-03)
|
|
93
|
+
raise ValueError(f'pattern regex non valido: {pattern!r} ({e})') from None
|
|
94
|
+
|
|
95
|
+
def matches(self, path, is_dir=False):
|
|
96
|
+
path = path.strip('/')
|
|
97
|
+
if not path:
|
|
98
|
+
return False
|
|
99
|
+
parts = path.split('/')
|
|
100
|
+
last = len(parts) - 1
|
|
101
|
+
for i, part in enumerate(parts):
|
|
102
|
+
if i == last and self.dir_only and not is_dir:
|
|
103
|
+
continue # foo/ non matcha un file chiamato foo
|
|
104
|
+
candidate = '/'.join(parts[:i + 1]) if self.anchored else part
|
|
105
|
+
if self._re.match(candidate):
|
|
106
|
+
return True # path o genitore directory
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_rule(line):
|
|
111
|
+
"""Rule da `include: <pattern>`/`exclude: <pattern>`; None per riga vuota/commento."""
|
|
112
|
+
s = strip_comment(line).strip()
|
|
113
|
+
if not s:
|
|
114
|
+
return None
|
|
115
|
+
m = re.match(r'(include|exclude)\s*:\s*(.*)\Z', s)
|
|
116
|
+
if not m:
|
|
117
|
+
raise ValueError(f'riga non valida (atteso include:/exclude:): {line.strip()!r}')
|
|
118
|
+
pattern = m.group(2).strip()
|
|
119
|
+
if not pattern:
|
|
120
|
+
raise ValueError(f'pattern vuoto: {line.strip()!r}')
|
|
121
|
+
return Rule(pattern, m.group(1) == 'include')
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_rules(lines, origin='<rules>'):
|
|
125
|
+
"""Layer di regole da una lista di righe; riga invalida → errore con numero di riga."""
|
|
126
|
+
rules = []
|
|
127
|
+
for lineno, raw in enumerate(lines, 1):
|
|
128
|
+
try:
|
|
129
|
+
rule = parse_rule(raw)
|
|
130
|
+
except ValueError as e:
|
|
131
|
+
raise ValueError(f'{origin}:{lineno}: {e}') from None
|
|
132
|
+
if rule is not None:
|
|
133
|
+
rules.append(rule)
|
|
134
|
+
return rules
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Matcher:
|
|
138
|
+
"""Layer di regole in ordine di priorità (bassa → alta), last-match-wins."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, *layers):
|
|
141
|
+
self.rules = [rule for layer in layers for rule in layer]
|
|
142
|
+
|
|
143
|
+
def evaluate(self, relpath: str, is_dir: bool = False) -> bool:
|
|
144
|
+
"""True = copiare. Ultima regola che matcha decide; nessun match →
|
|
145
|
+
allow-list (SPEC.md §4.3): file → False (non copiato), directory →
|
|
146
|
+
True (attraversata: si pota solo su un'esclusione esplicita)."""
|
|
147
|
+
for rule in reversed(self.rules):
|
|
148
|
+
if rule.matches(relpath, is_dir):
|
|
149
|
+
return rule.include
|
|
150
|
+
return is_dir
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Regole builtin (bassa priorità, sempre presenti — SPEC.md §5)
|
|
154
|
+
BUILTIN_RULES = parse_rules([
|
|
155
|
+
'exclude: .git/',
|
|
156
|
+
'exclude: node_modules/',
|
|
157
|
+
'exclude: .venv/',
|
|
158
|
+
'exclude: __pycache__/',
|
|
159
|
+
'exclude: .DS_Store',
|
|
160
|
+
'exclude: *.swp',
|
|
161
|
+
], origin='builtin')
|
safekeep/volumes.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Destinazioni non montate: stato, backoff e split (SPEC.md §8.3)."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
BACKOFF_CAP = 60 # secondi massimi fra due retry (niente busy-loop)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def dest_state(path) -> str:
|
|
8
|
+
"""`ok` se il mountpoint esiste (volume montato), `absent` altrimenti.
|
|
9
|
+
|
|
10
|
+
Solo `os.path.exists`: nessun processo figlio, nessun `mount` da scrapare.
|
|
11
|
+
"""
|
|
12
|
+
return 'ok' if os.path.exists(path) else 'absent'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def backoff_schedule():
|
|
16
|
+
"""1, 2, 4, 8, 16, 32, 60, 60, … — iteratore infinito con cap a 60s."""
|
|
17
|
+
delay = 1
|
|
18
|
+
while True:
|
|
19
|
+
yield min(delay, BACKOFF_CAP)
|
|
20
|
+
delay *= 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def partition_dests(dests):
|
|
24
|
+
"""Split delle dest in (ready, absent) sullo stato corrente del mountpoint."""
|
|
25
|
+
ready, absent = [], []
|
|
26
|
+
for d in dests:
|
|
27
|
+
(ready if dest_state(d) == 'ok' else absent).append(d)
|
|
28
|
+
return ready, absent
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: safekeep
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://gitlab.com/foxhound87/safekeep
|
|
7
|
+
Project-URL: Repository, https://gitlab.com/foxhound87/safekeep
|
|
8
|
+
Keywords: backup,macos,fswatch,sync,allow-list,launchd
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# safekeep
|
|
21
|
+
|
|
22
|
+
**Selective always-on backups for macOS**: watches source folders with
|
|
23
|
+
[fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
|
|
24
|
+
chose to their destinations — an **allow-list** model that **never deletes**
|
|
25
|
+
anything in the backup (a file removed or renamed at the source stays in the
|
|
26
|
+
backup).
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Allow-list**: a file is copied only if it matches at least one `include:`;
|
|
31
|
+
no match → not copied. Unmatched directories are still traversed, while
|
|
32
|
+
directories with an *excluded* verdict are pruned together with their subtree.
|
|
33
|
+
- **Atomic copy, never a half-written file**: the temp file lives in the
|
|
34
|
+
destination's own directory, followed by `fsync` + `os.replace` → a reader
|
|
35
|
+
sees either the old file or the new one, never a partial copy.
|
|
36
|
+
- **Reconcile** at startup, every 24h, on remount, and on `sync-once`: copies
|
|
37
|
+
whatever differs by size/mtime, idempotently — lost events, crashes and reboots
|
|
38
|
+
don't matter, the next reconcile brings everything back in sync.
|
|
39
|
+
- **Unmounted volumes**: missing destination → `pending` state with exponential
|
|
40
|
+
backoff (1s → 60s cap), then a reconcile once the mount is back.
|
|
41
|
+
- **launchd at boot**: an agent with `RunAtLoad` + `KeepAlive` keeps the process
|
|
42
|
+
alive and restarts it if it dies.
|
|
43
|
+
- **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
|
|
44
|
+
`.sync` file can neither add nor remove destinations (a `dest:` line is an
|
|
45
|
+
invalid line), so a repo cloned from a third party can't redirect the backup
|
|
46
|
+
somewhere else.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pipx install safekeep # recommended for a CLI
|
|
52
|
+
# or
|
|
53
|
+
pip install safekeep
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Requirements**: macOS, Python >= 3.9 and
|
|
57
|
+
[fswatch](https://github.com/emcrisostomo/fswatch):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
brew install fswatch
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The **launchd agent** (daemon at boot) is installed from a checkout of this
|
|
64
|
+
repository with `./install.sh` — not from the wheel — see
|
|
65
|
+
[Agent (launchd)](#agent-launchd) below.
|
|
66
|
+
|
|
67
|
+
## Agent (launchd)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://gitlab.com/foxhound87/safekeep.git
|
|
71
|
+
cd safekeep
|
|
72
|
+
|
|
73
|
+
# only external dependency, if not installed yet
|
|
74
|
+
brew install fswatch
|
|
75
|
+
|
|
76
|
+
# copies the example into ~/.safekeep, renders the plist, runs preflight checks
|
|
77
|
+
bash install.sh
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then:
|
|
81
|
+
|
|
82
|
+
1. Fill `~/.safekeep` with your real `dest:` entries — and, optionally, `source:`
|
|
83
|
+
(the example ships with placeholders — see
|
|
84
|
+
[`examples/safekeep.example`](examples/safekeep.example));
|
|
85
|
+
2. drop a `.sync` file in the root of every project you want to follow (see
|
|
86
|
+
[`examples/sync.example`](examples/sync.example)); a directory **without** a
|
|
87
|
+
`.sync` is not tracked;
|
|
88
|
+
3. load the agent:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
|
|
92
|
+
# unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
|
|
96
|
+
interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
|
|
97
|
+
|
|
98
|
+
## Quickstart
|
|
99
|
+
|
|
100
|
+
`~/.safekeep` (the only place where destinations live):
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# observed roots: only directories containing a .sync are followed
|
|
104
|
+
source: ~/Code
|
|
105
|
+
source: ~/Projects
|
|
106
|
+
|
|
107
|
+
# destination for ALL projects
|
|
108
|
+
dest: ~/Backup/safekeep
|
|
109
|
+
|
|
110
|
+
# relative (default): <dest>/<path relative to the source root>
|
|
111
|
+
# ~/Code/myapp/docs/a.md → ~/Backup/safekeep/myapp/docs/a.md
|
|
112
|
+
layout: relative
|
|
113
|
+
|
|
114
|
+
log_level: info
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`source:` is **optional**. With it present (source mode) the watched roots are
|
|
118
|
+
exactly those entries, as always. Without it safekeep switches to
|
|
119
|
+
**auto-discovery**: it scans `$HOME` for `.sync` files (skipping hidden
|
|
120
|
+
directories, `Library`, `.Trash`, `.cache`, `node_modules`, `.git`, `.venv`,
|
|
121
|
+
`__pycache__`, `venv`), watches `$HOME`, and picks up any `.sync` created after
|
|
122
|
+
startup. `dest:` stays mandatory in both modes.
|
|
123
|
+
|
|
124
|
+
`~/Code/myapp/.sync` (rules only, no destinations):
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
name: myapp
|
|
128
|
+
|
|
129
|
+
# allow-list semantics: without these lines NOT A SINGLE file would be copied
|
|
130
|
+
*.md
|
|
131
|
+
.env
|
|
132
|
+
!secrets/old.env # ! = exclude
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Rules can be written as bare gitignore-style lines — **the inverse of gitignore**:
|
|
136
|
+
a line lists what to **copy**, not what to ignore (`*.md` includes markdown here,
|
|
137
|
+
excludes it in a `.gitignore`), and a leading `!` turns it into an `exclude:`.
|
|
138
|
+
Bare lines share the same ordered list as the explicit `include:`/`exclude:`
|
|
139
|
+
keys, so last-match-wins works the same way (see
|
|
140
|
+
[`examples/sync.example`](examples/sync.example)).
|
|
141
|
+
|
|
142
|
+
Dry run first, then start the daemon:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
safekeep sync-once --dry-run # prints what would be copied, copies nothing
|
|
146
|
+
safekeep run # daemon: watch + copy (foreground)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## CLI commands
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
safekeep <command> [--config PATH] [-v]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| Command | What it does |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
158
|
+
| `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
|
|
159
|
+
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
160
|
+
| `doctor` | diagnostics: config, fswatch, TCC, launchd plist — exits non-zero if a fatal check fails |
|
|
161
|
+
|
|
162
|
+
## Tests
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
python3 -m unittest discover -s tests
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Stdlib (`unittest`) suite, zero dependencies: 156 tests covering the matcher,
|
|
169
|
+
config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
|
|
170
|
+
needed to run the tests.
|
|
171
|
+
|
|
172
|
+
## Documentation
|
|
173
|
+
|
|
174
|
+
Everything in detail (config format, pattern semantics, event → sync flow,
|
|
175
|
+
atomic copy, launchd, edge cases) lives in [`SPEC.md`](SPEC.md); commented
|
|
176
|
+
examples in [`examples/`](examples/).
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
safekeep/__init__.py,sha256=JYMz2afeGarcNRe9G1XF17PKF70cYS75cIh3EAt8ndU,178
|
|
2
|
+
safekeep/cli.py,sha256=b4JuHhuuKFukCl1wgXxYppF2UCcKvGhmBcnOPWipUY0,11392
|
|
3
|
+
safekeep/config.py,sha256=oAwUw5KITp8oyYQx8X9yF_ftRTh65CMRR4BmxjFrgwc,11668
|
|
4
|
+
safekeep/copier.py,sha256=KdGZe3lwKWepA_3qVX52BjFHfsmbQUsls_CVBKU9s4g,13750
|
|
5
|
+
safekeep/daemon.py,sha256=dXjJox00KXcjB6Hmsmy8BvLwSjbkASapFWg9W4USjRo,30394
|
|
6
|
+
safekeep/matcher.py,sha256=EwkE378Wo9-n56hw_wnD3un5UdRy0aG1Q-KM5nETvdQ,6008
|
|
7
|
+
safekeep/volumes.py,sha256=v3fBGp7Ir_IVBR2PQSU44_Oxk6DE3griBntPbTOwicQ,849
|
|
8
|
+
safekeep-0.2.0.dist-info/licenses/LICENSE,sha256=1WM2NMXTKpFlFQ2JItrUnblhj4Hybdo9U6gzipCh-Fs,1067
|
|
9
|
+
safekeep-0.2.0.dist-info/METADATA,sha256=T4oxx1D_mtY35n1df0egZJEKBv8yft_lQKyn4ZOHbfk,6457
|
|
10
|
+
safekeep-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
safekeep-0.2.0.dist-info/entry_points.txt,sha256=5XT-DVH6stDI99sx0dzDGsqP3zHTd92GBo8XhczThHg,47
|
|
12
|
+
safekeep-0.2.0.dist-info/top_level.txt,sha256=ZNrpaPE43W6IK3vPTA-U_R_RYt8f0T2J5-DS_NOKN6M,9
|
|
13
|
+
safekeep-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 foxhound87
|
|
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
|
+
safekeep
|