gitstore 0.1.0__tar.gz → 0.1.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: gitstore
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Utilities to encrypt files/directories with utilitz and exchange them through GitHub repositories.
5
5
  Author: artitzco
6
6
  License: MIT
@@ -55,7 +55,7 @@ from gitstore import GitStoreUploader, GitStoreDownloader
55
55
  from gitstore import GitStoreUploader
56
56
 
57
57
  uploader = GitStoreUploader(
58
- repo_path="C:/repos/my-publish-repo", # optional, defaults to current working directory
58
+ repo_path="C:/repos/my-publish-repo", # required
59
59
  security_level="high", # default
60
60
  )
61
61
 
@@ -43,10 +43,10 @@ from gitstore import GitStoreUploader, GitStoreDownloader
43
43
  ```python
44
44
  from gitstore import GitStoreUploader
45
45
 
46
- uploader = GitStoreUploader(
47
- repo_path="C:/repos/my-publish-repo", # optional, defaults to current working directory
48
- security_level="high", # default
49
- )
46
+ uploader = GitStoreUploader(
47
+ repo_path="C:/repos/my-publish-repo", # required
48
+ security_level="high", # default
49
+ )
50
50
 
51
51
  record = uploader.store(
52
52
  source_path="C:/data/documento.pdf", # file or directory
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitstore"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Utilities to encrypt files/directories with utilitz and exchange them through GitHub repositories."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,6 +2,8 @@ import json
2
2
  import os
3
3
  import shutil
4
4
  import tempfile
5
+ import hashlib
6
+ import re
5
7
  from dataclasses import dataclass
6
8
  from datetime import datetime, timezone
7
9
  from pathlib import Path
@@ -17,6 +19,7 @@ DEFAULT_PASSWORD_ENV_VAR = "GITSTORE_PASSWORD"
17
19
  class StoredArtifact:
18
20
  name: str
19
21
  artifact: str
22
+ artifact_hash: str
20
23
  is_directory: bool
21
24
  created_at_utc: str
22
25
 
@@ -31,19 +34,34 @@ def _resolve_password(password: str | None, password_env_var: str) -> str:
31
34
  return resolved_password
32
35
 
33
36
 
37
+ def _hash_from_name_or_content(encrypted_path: Path) -> str:
38
+ # utilitz default output name pattern: -confidential-<24hex>.asc
39
+ m = re.search(r"-confidential-([0-9a-fA-F]{24})", encrypted_path.name)
40
+ if m:
41
+ return m.group(1).lower()
42
+
43
+ hasher = hashlib.sha256()
44
+ with open(encrypted_path, "rb") as f:
45
+ for chunk in iter(lambda: f.read(65536), b""):
46
+ hasher.update(chunk)
47
+ return hasher.hexdigest()[:24]
48
+
49
+
34
50
  class GitStoreUploader:
35
51
  def __init__(
36
52
  self,
37
- repo_path: str | None = None,
53
+ repo_path: str,
38
54
  password: str | None = None,
39
55
  vault_dir: str = "vault",
40
56
  request_timeout: int = 60,
41
57
  password_env_var: str = DEFAULT_PASSWORD_ENV_VAR,
42
58
  security_level: str = "high",
43
59
  ) -> None:
60
+ if not repo_path:
61
+ raise ValueError("repo_path is required for uploader.")
44
62
  resolved_password = _resolve_password(password, password_env_var)
45
63
  self.config = GitStoreConfig(password=resolved_password, request_timeout=request_timeout)
46
- self.repo_path = Path(repo_path).expanduser().resolve() if repo_path else Path.cwd().resolve()
64
+ self.repo_path = Path(repo_path).expanduser().resolve()
47
65
  self.vault_dir = vault_dir.strip().strip("/\\") or "vault"
48
66
  self._manifest_rel = f"{self.vault_dir}/index.json"
49
67
  self.password_env_var = password_env_var
@@ -82,52 +100,70 @@ class GitStoreUploader:
82
100
  security_level=level,
83
101
  )
84
102
  )
85
- extension = "".join(encrypted_path.suffixes) or ".asc"
86
- stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
87
- artifact_filename = f"{name}__{stamp}{extension}"
88
- artifact_rel = f"{self.vault_dir}/{artifact_filename}"
89
- artifact_abs = self.repo_path / artifact_rel
90
- artifact_abs.parent.mkdir(parents=True, exist_ok=True)
91
- shutil.copy2(encrypted_path, artifact_abs)
92
103
 
93
- manifest = self._load_manifest_local()
94
- old_artifact_rel = None
95
- if name in manifest:
96
- if not replace_existing:
97
- raise ValueError(
98
- f"Name '{name}' already exists. Use replace_existing=True to replace it."
99
- )
100
- old_artifact = manifest[name].get("artifact")
101
- if old_artifact:
102
- old_artifact_rel = f"{self.vault_dir}/{old_artifact}"
103
- old_artifact_abs = self.repo_path / old_artifact_rel
104
- if old_artifact_abs.exists():
105
- old_artifact_abs.unlink()
106
-
107
- record = StoredArtifact(
108
- name=name,
109
- artifact=artifact_filename,
110
- is_directory=is_directory,
111
- created_at_utc=datetime.now(timezone.utc).isoformat(),
112
- )
113
- manifest[name] = record.__dict__
114
- self._save_manifest_local(manifest)
104
+ try:
105
+ extension = "".join(encrypted_path.suffixes) or ".asc"
106
+ artifact_hash = _hash_from_name_or_content(encrypted_path)
107
+ artifact_filename = f"{name}{extension}"
108
+ artifact_rel = f"{self.vault_dir}/{artifact_filename}"
109
+ artifact_abs = self.repo_path / artifact_rel
110
+ artifact_abs.parent.mkdir(parents=True, exist_ok=True)
115
111
 
116
- message = commit_message or f"gitstore: store '{name}'"
117
- paths_to_commit = [artifact_rel, self._manifest_rel]
118
- if old_artifact_rel:
119
- paths_to_commit.insert(0, old_artifact_rel)
120
- git_add_commit_push(
121
- repo_path=str(self.repo_path),
122
- paths_in_repo=paths_to_commit,
123
- commit_message=message,
124
- )
125
- if old_artifact_rel:
126
- git_purge_path_from_history(
112
+ manifest = self._load_manifest_local()
113
+ existing_record = manifest.get(name)
114
+ if existing_record:
115
+ existing_hash = str(existing_record.get("artifact_hash", "")).lower()
116
+ if existing_hash == artifact_hash:
117
+ # Same content already stored for the same logical name.
118
+ print(
119
+ f"[gitstore] Skip upload: '{name}' already up to date "
120
+ f"(hash={artifact_hash})."
121
+ )
122
+ return StoredArtifact(
123
+ name=name,
124
+ artifact=str(existing_record["artifact"]),
125
+ artifact_hash=existing_hash,
126
+ is_directory=bool(existing_record["is_directory"]),
127
+ created_at_utc=str(existing_record["created_at_utc"]),
128
+ )
129
+ if not replace_existing:
130
+ raise ValueError(
131
+ f"Name '{name}' already exists. Use replace_existing=True to replace it."
132
+ )
133
+
134
+ shutil.copy2(encrypted_path, artifact_abs)
135
+
136
+ record = StoredArtifact(
137
+ name=name,
138
+ artifact=artifact_filename,
139
+ artifact_hash=artifact_hash,
140
+ is_directory=is_directory,
141
+ created_at_utc=datetime.now(timezone.utc).isoformat(),
142
+ )
143
+ manifest[name] = {
144
+ "name": record.name,
145
+ "artifact": record.artifact,
146
+ "artifact_hash": record.artifact_hash,
147
+ "is_directory": record.is_directory,
148
+ "created_at_utc": record.created_at_utc,
149
+ }
150
+ self._save_manifest_local(manifest)
151
+
152
+ message = commit_message or f"gitstore: store '{name}'"
153
+ paths_to_commit = [artifact_rel, self._manifest_rel]
154
+ git_add_commit_push(
127
155
  repo_path=str(self.repo_path),
128
- path_in_repo=old_artifact_rel,
156
+ paths_in_repo=paths_to_commit,
157
+ commit_message=message,
129
158
  )
130
- return record
159
+ print(
160
+ f"[gitstore] Uploaded '{name}' -> '{artifact_rel}' "
161
+ f"(hash={artifact_hash})."
162
+ )
163
+ return record
164
+ finally:
165
+ if encrypted_path.exists():
166
+ encrypted_path.unlink()
131
167
 
132
168
  def destroy(self, name: str, commit_message: str | None = None) -> None:
133
169
  self._ensure_repo()
@@ -154,6 +190,7 @@ class GitStoreUploader:
154
190
  commit_message=message,
155
191
  )
156
192
  git_purge_path_from_history(repo_path=str(self.repo_path), path_in_repo=artifact_rel)
193
+ print(f"[gitstore] Destroyed '{name}' and purged '{artifact_rel}' from history.")
157
194
 
158
195
  def _ensure_repo(self) -> None:
159
196
  if not (self.repo_path / ".git").exists():
@@ -176,7 +213,7 @@ class GitStoreUploader:
176
213
  manifest_path = self._manifest_path()
177
214
  manifest_path.parent.mkdir(parents=True, exist_ok=True)
178
215
  with open(manifest_path, "w", encoding="utf-8", newline="\n") as f:
179
- json.dump(manifest, f, indent=2, sort_keys=True)
216
+ json.dump(manifest, f, indent=2, sort_keys=False)
180
217
  f.write("\n")
181
218
 
182
219
 
@@ -206,7 +243,34 @@ class GitStoreDownloader:
206
243
  ) -> str:
207
244
  record = self._get_manifest_record(name)
208
245
  artifact = record["artifact"]
246
+ artifact_hash = str(record.get("artifact_hash") or "")
209
247
  is_directory = bool(record["is_directory"])
248
+ local_index_path, expected_existing = self._local_registry_paths(
249
+ output_path=output_path,
250
+ is_directory=is_directory,
251
+ name=name,
252
+ )
253
+ local_index = self._load_local_download_index(local_index_path)
254
+ already_restored = False
255
+ for item in local_index:
256
+ if not isinstance(item, dict):
257
+ continue
258
+ if str(item.get("name") or "") != name:
259
+ continue
260
+ if str(item.get("artifact_hash") or "") != artifact_hash:
261
+ continue
262
+ recorded_path = str(item.get("restored_path") or "")
263
+ recorded_exists = Path(recorded_path).exists() if recorded_path else False
264
+ expected_exists = expected_existing.exists() if expected_existing else False
265
+ if expected_exists or recorded_exists:
266
+ already_restored = True
267
+ break
268
+ if already_restored:
269
+ print(
270
+ f"[gitstore] Skip download: '{name}' already restored "
271
+ f"(hash={artifact_hash})."
272
+ )
273
+ return str(expected_existing)
210
274
 
211
275
  temp_dir = Path(tempfile.mkdtemp(prefix="gitstore_restore_"))
212
276
  temp_file = temp_dir / "artifact.enc"
@@ -214,18 +278,34 @@ class GitStoreDownloader:
214
278
  raw_url = f"{self.raw_base_url}/{self.vault_dir}/{artifact}"
215
279
  download_raw_file(raw_url, str(temp_file), timeout=self.config.request_timeout)
216
280
  if is_directory:
217
- return decrypt_directory(
281
+ restored_path = decrypt_directory(
282
+ encrypted_path=str(temp_file),
283
+ config=self.config,
284
+ output_path=output_path,
285
+ overwrite=overwrite,
286
+ )
287
+ else:
288
+ restored_path = decrypt_file(
218
289
  encrypted_path=str(temp_file),
219
290
  config=self.config,
220
291
  output_path=output_path,
221
292
  overwrite=overwrite,
222
293
  )
223
- return decrypt_file(
224
- encrypted_path=str(temp_file),
225
- config=self.config,
226
- output_path=output_path,
227
- overwrite=overwrite,
294
+ local_index.append({
295
+ "name": name,
296
+ "artifact": artifact,
297
+ "artifact_hash": artifact_hash,
298
+ "is_directory": is_directory,
299
+ "created_at_utc": str(record.get("created_at_utc", "")),
300
+ "restored_path": restored_path,
301
+ "downloaded_at_utc": datetime.now(timezone.utc).isoformat(),
302
+ })
303
+ self._save_local_download_index(local_index_path, local_index)
304
+ print(
305
+ f"[gitstore] Downloaded and restored '{name}' -> '{restored_path}' "
306
+ f"(hash={artifact_hash})."
228
307
  )
308
+ return restored_path
229
309
  finally:
230
310
  shutil.rmtree(temp_dir, ignore_errors=True)
231
311
 
@@ -247,3 +327,37 @@ class GitStoreDownloader:
247
327
  raise ValueError(f"Invalid manifest record for name: {name}")
248
328
  return record
249
329
 
330
+ def _local_registry_paths(
331
+ self,
332
+ output_path: str | None,
333
+ is_directory: bool,
334
+ name: str,
335
+ ) -> tuple[Path, Path | None]:
336
+ if output_path:
337
+ out = Path(output_path).expanduser().resolve()
338
+ target = out
339
+ else:
340
+ # Conservative default when no explicit output path is passed.
341
+ base = Path.cwd().resolve()
342
+ target = base / name
343
+
344
+ # Keep metadata file at the same level as the restored artifact.
345
+ base_dir = target.parent
346
+ idx = base_dir / ".gitstore.json"
347
+ return idx, target
348
+
349
+ def _load_local_download_index(self, index_path: Path) -> list[dict]:
350
+ if not index_path.exists():
351
+ return []
352
+ with open(index_path, "r", encoding="utf-8") as f:
353
+ data = json.load(f)
354
+ if not isinstance(data, list):
355
+ raise ValueError(f"Invalid local download index format: {index_path}")
356
+ return [item for item in data if isinstance(item, dict)]
357
+
358
+ def _save_local_download_index(self, index_path: Path, data: list[dict]) -> None:
359
+ index_path.parent.mkdir(parents=True, exist_ok=True)
360
+ with open(index_path, "w", encoding="utf-8", newline="\n") as f:
361
+ json.dump(data, f, indent=2, sort_keys=False)
362
+ f.write("\n")
363
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitstore
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Utilities to encrypt files/directories with utilitz and exchange them through GitHub repositories.
5
5
  Author: artitzco
6
6
  License: MIT
@@ -55,7 +55,7 @@ from gitstore import GitStoreUploader, GitStoreDownloader
55
55
  from gitstore import GitStoreUploader
56
56
 
57
57
  uploader = GitStoreUploader(
58
- repo_path="C:/repos/my-publish-repo", # optional, defaults to current working directory
58
+ repo_path="C:/repos/my-publish-repo", # required
59
59
  security_level="high", # default
60
60
  )
61
61
 
File without changes