project-packer 1.0.0__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.
- project_packer-1.0.0/PKG-INFO +19 -0
- project_packer-1.0.0/packer.py +516 -0
- project_packer-1.0.0/project_packer.egg-info/PKG-INFO +19 -0
- project_packer-1.0.0/project_packer.egg-info/SOURCES.txt +7 -0
- project_packer-1.0.0/project_packer.egg-info/dependency_links.txt +1 -0
- project_packer-1.0.0/project_packer.egg-info/entry_points.txt +2 -0
- project_packer-1.0.0/project_packer.egg-info/top_level.txt +1 -0
- project_packer-1.0.0/setup.cfg +4 -0
- project_packer-1.0.0/setup.py +29 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: project-packer
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pack any Python project into a single file for AI sharing, respecting setup.py and MANIFEST.in
|
|
5
|
+
Home-page: https://github.com/yourusername/project-packer
|
|
6
|
+
Author: Udaya Raj Joshi
|
|
7
|
+
Author-email: udayarajjoshi@aol.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Dynamic: author
|
|
14
|
+
Dynamic: author-email
|
|
15
|
+
Dynamic: classifier
|
|
16
|
+
Dynamic: description-content-type
|
|
17
|
+
Dynamic: home-page
|
|
18
|
+
Dynamic: requires-python
|
|
19
|
+
Dynamic: summary
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Universal Project Packer / Unpacker – Setuptools‑Aware (MANIFEST.in only)
|
|
4
|
+
|
|
5
|
+
Pack:
|
|
6
|
+
python packer.py pack [root] [-o output] [-v] [--respect-gitignore] [--manifest FILE]
|
|
7
|
+
|
|
8
|
+
Unpack:
|
|
9
|
+
python packer.py unpack <packed_file> [target_dir] [-v]
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import ast
|
|
15
|
+
import time
|
|
16
|
+
import fnmatch
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# ---------- Default ignore patterns ----------
|
|
20
|
+
DEFAULT_IGNORE = {
|
|
21
|
+
'__pycache__', '.git', '.venv', 'venv', '.tox', '.eggs', 'dist', 'build',
|
|
22
|
+
'*.egg-info', '*.egg', '*.cache', '.pytest_cache', '.mypy_cache', '.ruff_cache',
|
|
23
|
+
'*.pyc', '*.pyo', '*.so', '*.dll', '*.exe', '*.log', '*.tmp',
|
|
24
|
+
'*.json', '*.csv', '*.txt.bak', '*.pickle', 'cookies.txt', 'youtube_token.pickle',
|
|
25
|
+
'client_secrets.json', 'gallery-dl.conf',
|
|
26
|
+
'*.bak', '*.backup', '*~', '.DS_Store', 'Thumbs.db',
|
|
27
|
+
'*.mp4', '*.mkv', '*.avi', '*.mov', '*.webm', '*.tif', '*.tiff',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# ---------- MANIFEST.in parser (also used for packer.manifest) ----------
|
|
31
|
+
class ManifestParser:
|
|
32
|
+
def __init__(self, root):
|
|
33
|
+
self.root = Path(root).resolve()
|
|
34
|
+
self.includes = set() # exact patterns or (dir, pattern)
|
|
35
|
+
self.excludes = set()
|
|
36
|
+
self.global_includes = set()
|
|
37
|
+
self.global_excludes = set()
|
|
38
|
+
|
|
39
|
+
def parse(self, manifest_path):
|
|
40
|
+
if not manifest_path.exists():
|
|
41
|
+
return
|
|
42
|
+
with open(manifest_path, 'r') as f:
|
|
43
|
+
for line in f:
|
|
44
|
+
line = line.strip()
|
|
45
|
+
if not line or line.startswith('#'):
|
|
46
|
+
continue
|
|
47
|
+
parts = line.split()
|
|
48
|
+
if not parts:
|
|
49
|
+
continue
|
|
50
|
+
cmd = parts[0]
|
|
51
|
+
args = parts[1:]
|
|
52
|
+
self._process_directive(cmd, args)
|
|
53
|
+
|
|
54
|
+
def _process_directive(self, cmd, args):
|
|
55
|
+
if cmd == 'include':
|
|
56
|
+
for pat in args:
|
|
57
|
+
self.includes.add(pat)
|
|
58
|
+
elif cmd == 'exclude':
|
|
59
|
+
for pat in args:
|
|
60
|
+
self.excludes.add(pat)
|
|
61
|
+
elif cmd == 'recursive-include':
|
|
62
|
+
if len(args) >= 2:
|
|
63
|
+
dirname, pat = args[0], args[1]
|
|
64
|
+
self.includes.add((dirname, pat))
|
|
65
|
+
elif cmd == 'recursive-exclude':
|
|
66
|
+
if len(args) >= 2:
|
|
67
|
+
dirname, pat = args[0], args[1]
|
|
68
|
+
self.excludes.add((dirname, pat))
|
|
69
|
+
elif cmd == 'graft':
|
|
70
|
+
for dirname in args:
|
|
71
|
+
self.includes.add(dirname)
|
|
72
|
+
elif cmd == 'prune':
|
|
73
|
+
for dirname in args:
|
|
74
|
+
self.excludes.add(dirname)
|
|
75
|
+
elif cmd == 'global-include':
|
|
76
|
+
for pat in args:
|
|
77
|
+
self.global_includes.add(pat)
|
|
78
|
+
elif cmd == 'global-exclude':
|
|
79
|
+
for pat in args:
|
|
80
|
+
self.global_excludes.add(pat)
|
|
81
|
+
|
|
82
|
+
def matches_include(self, rel_path):
|
|
83
|
+
# Global include
|
|
84
|
+
for pat in self.global_includes:
|
|
85
|
+
if fnmatch.fnmatch(rel_path, pat):
|
|
86
|
+
return True
|
|
87
|
+
# Explicit includes
|
|
88
|
+
for item in self.includes:
|
|
89
|
+
if isinstance(item, tuple):
|
|
90
|
+
dirname, pat = item
|
|
91
|
+
if rel_path.startswith(dirname) and fnmatch.fnmatch(rel_path, f"{dirname}/{pat}"):
|
|
92
|
+
return True
|
|
93
|
+
else:
|
|
94
|
+
# string pattern or directory name
|
|
95
|
+
if '/' not in item and '.' not in item:
|
|
96
|
+
# directory (graft)
|
|
97
|
+
if rel_path.startswith(item):
|
|
98
|
+
return True
|
|
99
|
+
else:
|
|
100
|
+
if fnmatch.fnmatch(rel_path, item):
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def matches_exclude(self, rel_path):
|
|
105
|
+
# Global exclude
|
|
106
|
+
for pat in self.global_excludes:
|
|
107
|
+
if fnmatch.fnmatch(rel_path, pat):
|
|
108
|
+
return True
|
|
109
|
+
# Explicit excludes
|
|
110
|
+
for item in self.excludes:
|
|
111
|
+
if isinstance(item, tuple):
|
|
112
|
+
dirname, pat = item
|
|
113
|
+
if rel_path.startswith(dirname) and fnmatch.fnmatch(rel_path, f"{dirname}/{pat}"):
|
|
114
|
+
return True
|
|
115
|
+
else:
|
|
116
|
+
if '/' not in item and '.' not in item:
|
|
117
|
+
# directory (prune)
|
|
118
|
+
if rel_path.startswith(item):
|
|
119
|
+
return True
|
|
120
|
+
else:
|
|
121
|
+
if fnmatch.fnmatch(rel_path, item):
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
# ---------- .gitignore parser ----------
|
|
126
|
+
def parse_gitignore(root):
|
|
127
|
+
gitignore_path = Path(root) / '.gitignore'
|
|
128
|
+
if not gitignore_path.exists():
|
|
129
|
+
return [], []
|
|
130
|
+
includes = []
|
|
131
|
+
excludes = []
|
|
132
|
+
with open(gitignore_path, 'r') as f:
|
|
133
|
+
for line in f:
|
|
134
|
+
line = line.strip()
|
|
135
|
+
if not line or line.startswith('#'):
|
|
136
|
+
continue
|
|
137
|
+
if line.startswith('!'):
|
|
138
|
+
includes.append(line[1:])
|
|
139
|
+
else:
|
|
140
|
+
excludes.append(line)
|
|
141
|
+
return includes, excludes
|
|
142
|
+
|
|
143
|
+
# ---------- setup.py parsing (minimal) ----------
|
|
144
|
+
def parse_setup_py(setup_path, verbose=False):
|
|
145
|
+
if not setup_path.exists():
|
|
146
|
+
if verbose:
|
|
147
|
+
print(" ⚠️ setup.py not found.")
|
|
148
|
+
return None, None, None, False, {}
|
|
149
|
+
|
|
150
|
+
if verbose:
|
|
151
|
+
print("🔍 Parsing setup.py")
|
|
152
|
+
|
|
153
|
+
with open(setup_path, 'r', encoding='utf-8') as f:
|
|
154
|
+
content = f.read()
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
tree = ast.parse(content)
|
|
158
|
+
except SyntaxError:
|
|
159
|
+
if verbose:
|
|
160
|
+
print(" ⚠️ Syntax error in setup.py")
|
|
161
|
+
return None, None, None, False, {}
|
|
162
|
+
|
|
163
|
+
name = version = None
|
|
164
|
+
packages = None
|
|
165
|
+
include_package_data = False
|
|
166
|
+
entry_points = {}
|
|
167
|
+
|
|
168
|
+
for node in ast.walk(tree):
|
|
169
|
+
if isinstance(node, ast.Call):
|
|
170
|
+
if isinstance(node.func, ast.Name) and node.func.id == 'setup':
|
|
171
|
+
for kw in node.keywords:
|
|
172
|
+
if kw.arg == 'name' and isinstance(kw.value, ast.Constant):
|
|
173
|
+
name = kw.value.value
|
|
174
|
+
elif kw.arg == 'version' and isinstance(kw.value, ast.Constant):
|
|
175
|
+
version = kw.value.value
|
|
176
|
+
elif kw.arg == 'packages':
|
|
177
|
+
if isinstance(kw.value, ast.List):
|
|
178
|
+
packages = [elt.value for elt in kw.value.elts if isinstance(elt, ast.Constant)]
|
|
179
|
+
elif isinstance(kw.value, ast.Call) and isinstance(kw.value.func, ast.Name) and kw.value.func.id == 'find_packages':
|
|
180
|
+
packages = None
|
|
181
|
+
elif kw.arg == 'include_package_data' and isinstance(kw.value, ast.Constant):
|
|
182
|
+
include_package_data = kw.value.value
|
|
183
|
+
elif kw.arg == 'entry_points' and isinstance(kw.value, ast.Dict):
|
|
184
|
+
for key, val in zip(kw.value.keys, kw.value.values):
|
|
185
|
+
if isinstance(key, ast.Constant) and key.value == 'console_scripts':
|
|
186
|
+
if isinstance(val, ast.List):
|
|
187
|
+
for elt in val.elts:
|
|
188
|
+
if isinstance(elt, ast.Constant):
|
|
189
|
+
entry_points[elt.value] = True
|
|
190
|
+
|
|
191
|
+
if verbose:
|
|
192
|
+
print(f" 📦 Project name: {name or 'unknown'}, version: {version or 'unknown'}")
|
|
193
|
+
if packages:
|
|
194
|
+
print(f" 📂 Packages: {', '.join(packages)}")
|
|
195
|
+
else:
|
|
196
|
+
print(" 📂 No explicit packages – using find_packages()")
|
|
197
|
+
print(f" 📂 include_package_data: {include_package_data}")
|
|
198
|
+
if entry_points:
|
|
199
|
+
print(f" 📂 Entry points: {', '.join(entry_points.keys())}")
|
|
200
|
+
return name, version, packages, include_package_data, entry_points
|
|
201
|
+
|
|
202
|
+
# ---------- File collection using manifest ----------
|
|
203
|
+
def is_ignored_by_default(rel_path):
|
|
204
|
+
"""Check if a file path matches default ignore patterns."""
|
|
205
|
+
parts = rel_path.split(os.sep)
|
|
206
|
+
for part in parts:
|
|
207
|
+
if part in DEFAULT_IGNORE or any(fnmatch.fnmatch(part, pat) for pat in DEFAULT_IGNORE if '*' in pat):
|
|
208
|
+
return True
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
def collect_files(root, verbose=False, respect_gitignore=False, manifest_file=None):
|
|
212
|
+
root_path = Path(root).resolve()
|
|
213
|
+
files = set()
|
|
214
|
+
reasons = {} # store reason for each file
|
|
215
|
+
|
|
216
|
+
# Parse setup.py for metadata
|
|
217
|
+
name, version, packages, include_package_data, entry_points = parse_setup_py(root_path / 'setup.py', verbose)
|
|
218
|
+
|
|
219
|
+
# Determine which manifest to use
|
|
220
|
+
if manifest_file is not None:
|
|
221
|
+
manifest_path = Path(manifest_file)
|
|
222
|
+
manifest_source = "custom"
|
|
223
|
+
else:
|
|
224
|
+
# Look for packer.manifest first, then MANIFEST.in
|
|
225
|
+
packer_manifest = root_path / 'packer.manifest'
|
|
226
|
+
if packer_manifest.exists():
|
|
227
|
+
manifest_path = packer_manifest
|
|
228
|
+
manifest_source = "packer.manifest"
|
|
229
|
+
else:
|
|
230
|
+
manifest_path = root_path / 'MANIFEST.in'
|
|
231
|
+
manifest_source = "MANIFEST.in"
|
|
232
|
+
|
|
233
|
+
manifest = ManifestParser(root_path)
|
|
234
|
+
has_manifest = manifest_path.exists()
|
|
235
|
+
if has_manifest:
|
|
236
|
+
if verbose:
|
|
237
|
+
print(f"📄 Parsing {manifest_source} ({manifest_path})")
|
|
238
|
+
manifest.parse(manifest_path)
|
|
239
|
+
if verbose:
|
|
240
|
+
print(f" ✅ Found {len(manifest.includes)} include rules, {len(manifest.excludes)} exclude rules")
|
|
241
|
+
else:
|
|
242
|
+
if verbose:
|
|
243
|
+
print("⚠️ No manifest file found – will include all non‑ignored files.")
|
|
244
|
+
|
|
245
|
+
# Step 1: If manifest exists, apply its rules
|
|
246
|
+
if has_manifest:
|
|
247
|
+
# Scan all files and apply include/exclude
|
|
248
|
+
for item in root_path.rglob('*'):
|
|
249
|
+
if item.is_file():
|
|
250
|
+
rel = str(item.relative_to(root_path))
|
|
251
|
+
# Skip if it matches an exclude rule
|
|
252
|
+
if manifest.matches_exclude(rel):
|
|
253
|
+
if verbose:
|
|
254
|
+
print(f" 🚫 Excluded (manifest): {rel}")
|
|
255
|
+
continue
|
|
256
|
+
# Check if it matches any include rule
|
|
257
|
+
if manifest.matches_include(rel):
|
|
258
|
+
if not is_ignored_by_default(rel):
|
|
259
|
+
files.add(rel)
|
|
260
|
+
reasons[rel] = "manifest include"
|
|
261
|
+
if verbose:
|
|
262
|
+
print(f" ✅ Include (manifest): {rel}")
|
|
263
|
+
else:
|
|
264
|
+
if verbose:
|
|
265
|
+
print(f" ⏭️ Skipped (default ignore): {rel}")
|
|
266
|
+
else:
|
|
267
|
+
if verbose:
|
|
268
|
+
print(f" ⏭️ Not included (no manifest rule): {rel}")
|
|
269
|
+
|
|
270
|
+
# Add metadata files that are not excluded and not already added
|
|
271
|
+
for meta in ['setup.py', 'README.md', 'README.rst', 'README.txt', 'LICENSE', 'MANIFEST.in', 'packer.manifest']:
|
|
272
|
+
if (root_path / meta).exists():
|
|
273
|
+
rel = meta
|
|
274
|
+
if rel not in files and not manifest.matches_exclude(rel):
|
|
275
|
+
if not is_ignored_by_default(rel):
|
|
276
|
+
files.add(rel)
|
|
277
|
+
reasons[rel] = "metadata (default)"
|
|
278
|
+
if verbose:
|
|
279
|
+
print(f" ✅ Include (metadata): {rel}")
|
|
280
|
+
else:
|
|
281
|
+
# No manifest: include all non-ignored files
|
|
282
|
+
if verbose:
|
|
283
|
+
print("📂 Including all non‑ignored files (no manifest)")
|
|
284
|
+
for item in root_path.rglob('*'):
|
|
285
|
+
if item.is_file():
|
|
286
|
+
rel = str(item.relative_to(root_path))
|
|
287
|
+
if not is_ignored_by_default(rel):
|
|
288
|
+
files.add(rel)
|
|
289
|
+
reasons[rel] = "all files (fallback)"
|
|
290
|
+
if verbose:
|
|
291
|
+
print(f" ✅ Include (fallback): {rel}")
|
|
292
|
+
else:
|
|
293
|
+
if verbose:
|
|
294
|
+
print(f" ⏭️ Skipped (default ignore): {rel}")
|
|
295
|
+
|
|
296
|
+
# Add metadata even if they were ignored by default (e.g., setup.py)
|
|
297
|
+
for meta in ['setup.py', 'README.md', 'README.rst', 'README.txt', 'LICENSE', 'MANIFEST.in', 'packer.manifest']:
|
|
298
|
+
if (root_path / meta).exists():
|
|
299
|
+
if meta not in files:
|
|
300
|
+
files.add(meta)
|
|
301
|
+
reasons[meta] = "metadata (forced)"
|
|
302
|
+
if verbose:
|
|
303
|
+
print(f" ✅ Include (metadata): {meta}")
|
|
304
|
+
|
|
305
|
+
# Apply .gitignore exclusions if requested
|
|
306
|
+
if respect_gitignore:
|
|
307
|
+
if verbose:
|
|
308
|
+
print("📂 Applying .gitignore rules")
|
|
309
|
+
git_includes, git_excludes = parse_gitignore(root_path)
|
|
310
|
+
for f in list(files):
|
|
311
|
+
excluded = False
|
|
312
|
+
for pat in git_excludes:
|
|
313
|
+
if fnmatch.fnmatch(f, pat):
|
|
314
|
+
excluded = True
|
|
315
|
+
break
|
|
316
|
+
if excluded:
|
|
317
|
+
# Check if it's re-included by ! patterns
|
|
318
|
+
for inc_pat in git_includes:
|
|
319
|
+
if fnmatch.fnmatch(f, inc_pat):
|
|
320
|
+
excluded = False
|
|
321
|
+
break
|
|
322
|
+
if excluded:
|
|
323
|
+
files.remove(f)
|
|
324
|
+
if verbose:
|
|
325
|
+
print(f" 🚫 Excluded (.gitignore): {f}")
|
|
326
|
+
|
|
327
|
+
if verbose:
|
|
328
|
+
print(f"\n📊 Total files collected: {len(files)}")
|
|
329
|
+
# Optionally list all with reasons
|
|
330
|
+
if len(files) <= 50:
|
|
331
|
+
for f in sorted(files):
|
|
332
|
+
print(f" • {f} ({reasons.get(f, 'unknown')})")
|
|
333
|
+
else:
|
|
334
|
+
print(f" (First 10 files shown)")
|
|
335
|
+
for f in sorted(files)[:10]:
|
|
336
|
+
print(f" • {f} ({reasons.get(f, 'unknown')})")
|
|
337
|
+
print(f" ... and {len(files)-10} more")
|
|
338
|
+
|
|
339
|
+
return sorted(files)
|
|
340
|
+
|
|
341
|
+
# ---------- Pack ----------
|
|
342
|
+
def pack_project(root, output_file=None, verbose=False, force=False, respect_gitignore=False, manifest_file=None):
|
|
343
|
+
root = Path(root).resolve()
|
|
344
|
+
if not root.is_dir():
|
|
345
|
+
print(f"Error: {root} is not a directory")
|
|
346
|
+
sys.exit(1)
|
|
347
|
+
|
|
348
|
+
if not (root / 'setup.py').exists() and not (root / '.git').exists():
|
|
349
|
+
if force:
|
|
350
|
+
print("⚠️ --force: continuing without setup.py or .git")
|
|
351
|
+
else:
|
|
352
|
+
print("❌ Not a Python project root (no setup.py or .git).")
|
|
353
|
+
print(" Use --force to override this check.")
|
|
354
|
+
sys.exit(1)
|
|
355
|
+
|
|
356
|
+
if verbose:
|
|
357
|
+
print(f"\n📦 Packing project from: {root}")
|
|
358
|
+
|
|
359
|
+
name, version, _, _, _ = parse_setup_py(root / 'setup.py', verbose)
|
|
360
|
+
if not name:
|
|
361
|
+
name = root.name
|
|
362
|
+
if verbose:
|
|
363
|
+
print(f" ℹ️ Using directory name as project name: {name}")
|
|
364
|
+
if not version:
|
|
365
|
+
version = 'unknown'
|
|
366
|
+
|
|
367
|
+
if output_file is None:
|
|
368
|
+
if version and version != 'unknown':
|
|
369
|
+
output_file = f"{name}-{version}.py"
|
|
370
|
+
else:
|
|
371
|
+
output_file = f"{name}.py"
|
|
372
|
+
if verbose:
|
|
373
|
+
print(f"📄 Output file: {output_file}")
|
|
374
|
+
|
|
375
|
+
files = collect_files(str(root), verbose, respect_gitignore, manifest_file)
|
|
376
|
+
|
|
377
|
+
with open(output_file, 'w', encoding='utf-8') as out:
|
|
378
|
+
out.write(f"# Project: {name}\n")
|
|
379
|
+
out.write(f"# Version: {version}\n")
|
|
380
|
+
out.write(f"# Packed on: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
381
|
+
out.write(f"# Total files: {len(files)}\n")
|
|
382
|
+
out.write("#" + "=" * 70 + "\n")
|
|
383
|
+
out.write("# This file contains the complete source code.\n")
|
|
384
|
+
out.write("# To unpack, use: python packer.py unpack <this_file>\n")
|
|
385
|
+
out.write("#" + "=" * 70 + "\n\n")
|
|
386
|
+
|
|
387
|
+
written = 0
|
|
388
|
+
for rel_path in files:
|
|
389
|
+
full_path = root / rel_path
|
|
390
|
+
if not full_path.exists():
|
|
391
|
+
if verbose:
|
|
392
|
+
print(f" ⚠️ File missing: {rel_path} (skipping)")
|
|
393
|
+
continue
|
|
394
|
+
if verbose:
|
|
395
|
+
print(f" 📄 Writing: {rel_path}")
|
|
396
|
+
out.write(f"#---- FILE: {rel_path} ----\n")
|
|
397
|
+
try:
|
|
398
|
+
with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
399
|
+
content = f.read()
|
|
400
|
+
out.write(content)
|
|
401
|
+
except UnicodeDecodeError:
|
|
402
|
+
out.write(f"[Binary file: {rel_path} - content not shown]\n")
|
|
403
|
+
if verbose:
|
|
404
|
+
print(f" ⚠️ Binary file – content skipped")
|
|
405
|
+
if not content.endswith('\n'):
|
|
406
|
+
out.write('\n')
|
|
407
|
+
out.write(f"#---- END FILE: {rel_path} ----\n\n")
|
|
408
|
+
written += 1
|
|
409
|
+
|
|
410
|
+
print(f"✅ Packed {written} files into {output_file}")
|
|
411
|
+
|
|
412
|
+
# ---------- Unpack (unchanged) ----------
|
|
413
|
+
def unpack_project(packed_file, target_dir=None, verbose=False):
|
|
414
|
+
if not os.path.exists(packed_file):
|
|
415
|
+
print(f"Error: {packed_file} not found")
|
|
416
|
+
sys.exit(1)
|
|
417
|
+
|
|
418
|
+
name = None
|
|
419
|
+
version = None
|
|
420
|
+
try:
|
|
421
|
+
with open(packed_file, 'r', encoding='utf-8') as f:
|
|
422
|
+
for line in f:
|
|
423
|
+
if line.startswith('# Project: '):
|
|
424
|
+
name = line[len('# Project: '):].strip()
|
|
425
|
+
elif line.startswith('# Version: '):
|
|
426
|
+
version = line[len('# Version: '):].strip()
|
|
427
|
+
if name and version:
|
|
428
|
+
break
|
|
429
|
+
except:
|
|
430
|
+
pass
|
|
431
|
+
|
|
432
|
+
if target_dir is None:
|
|
433
|
+
if name and version:
|
|
434
|
+
target_dir = f"{name}-{version}"
|
|
435
|
+
else:
|
|
436
|
+
base = os.path.splitext(os.path.basename(packed_file))[0]
|
|
437
|
+
target_dir = base + "_unpacked"
|
|
438
|
+
target = Path(target_dir)
|
|
439
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
|
|
441
|
+
if verbose:
|
|
442
|
+
print(f"📂 Unpacking to: {target_dir}")
|
|
443
|
+
|
|
444
|
+
with open(packed_file, 'r', encoding='utf-8') as f:
|
|
445
|
+
lines = f.readlines()
|
|
446
|
+
|
|
447
|
+
current_file = None
|
|
448
|
+
content_lines = []
|
|
449
|
+
file_count = 0
|
|
450
|
+
|
|
451
|
+
for line in lines:
|
|
452
|
+
if line.startswith('#---- FILE: '):
|
|
453
|
+
if current_file is not None:
|
|
454
|
+
write_file(target / current_file, ''.join(content_lines), verbose)
|
|
455
|
+
file_count += 1
|
|
456
|
+
content_lines = []
|
|
457
|
+
raw = line[len('#---- FILE: '):].strip()
|
|
458
|
+
if raw.endswith('----'):
|
|
459
|
+
raw = raw[:-4].rstrip()
|
|
460
|
+
current_file = raw
|
|
461
|
+
if verbose:
|
|
462
|
+
print(f" 📄 Creating: {current_file}")
|
|
463
|
+
continue
|
|
464
|
+
elif line.startswith('#---- END FILE: '):
|
|
465
|
+
if current_file is not None:
|
|
466
|
+
write_file(target / current_file, ''.join(content_lines), verbose)
|
|
467
|
+
file_count += 1
|
|
468
|
+
current_file = None
|
|
469
|
+
content_lines = []
|
|
470
|
+
continue
|
|
471
|
+
if current_file is not None:
|
|
472
|
+
content_lines.append(line)
|
|
473
|
+
|
|
474
|
+
if current_file is not None and content_lines:
|
|
475
|
+
write_file(target / current_file, ''.join(content_lines), verbose)
|
|
476
|
+
file_count += 1
|
|
477
|
+
|
|
478
|
+
print(f"✅ Unpacked {file_count} files into {target_dir}")
|
|
479
|
+
|
|
480
|
+
def write_file(path, content, verbose=False):
|
|
481
|
+
parent = path.parent
|
|
482
|
+
if not parent.exists():
|
|
483
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
with open(path, 'w', encoding='utf-8', errors='ignore') as f:
|
|
485
|
+
f.write(content)
|
|
486
|
+
|
|
487
|
+
# ---------- CLI ----------
|
|
488
|
+
def main():
|
|
489
|
+
import argparse
|
|
490
|
+
parser = argparse.ArgumentParser(description='Pack or unpack a Python project.')
|
|
491
|
+
subparsers = parser.add_subparsers(dest='command', required=True)
|
|
492
|
+
|
|
493
|
+
pack_parser = subparsers.add_parser('pack', help='Pack project')
|
|
494
|
+
pack_parser.add_argument('root', nargs='?', default='.', help='Project root (must contain setup.py or .git)')
|
|
495
|
+
pack_parser.add_argument('-o', '--output', help='Output file name (default: <project_name>-<version>.py)')
|
|
496
|
+
pack_parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
|
|
497
|
+
pack_parser.add_argument('-f', '--force', action='store_true', help='Force packing even without setup.py/.git')
|
|
498
|
+
pack_parser.add_argument('--respect-gitignore', action='store_true', help='Also apply .gitignore exclusions')
|
|
499
|
+
pack_parser.add_argument('--manifest', help='Custom manifest file (default: packer.manifest, then MANIFEST.in)')
|
|
500
|
+
|
|
501
|
+
unpack_parser = subparsers.add_parser('unpack', help='Unpack a packed file')
|
|
502
|
+
unpack_parser.add_argument('packed_file', help='Packed file to unpack')
|
|
503
|
+
unpack_parser.add_argument('target', nargs='?', help='Target directory (default: <name>-<version> if available, else <base>_unpacked)')
|
|
504
|
+
unpack_parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
|
|
505
|
+
|
|
506
|
+
args = parser.parse_args()
|
|
507
|
+
|
|
508
|
+
if args.command == 'pack':
|
|
509
|
+
pack_project(args.root, args.output, args.verbose, args.force, args.respect_gitignore, args.manifest)
|
|
510
|
+
elif args.command == 'unpack':
|
|
511
|
+
unpack_project(args.packed_file, args.target, args.verbose)
|
|
512
|
+
else:
|
|
513
|
+
parser.print_help()
|
|
514
|
+
|
|
515
|
+
if __name__ == "__main__":
|
|
516
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: project-packer
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pack any Python project into a single file for AI sharing, respecting setup.py and MANIFEST.in
|
|
5
|
+
Home-page: https://github.com/yourusername/project-packer
|
|
6
|
+
Author: Udaya Raj Joshi
|
|
7
|
+
Author-email: udayarajjoshi@aol.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Dynamic: author
|
|
14
|
+
Dynamic: author-email
|
|
15
|
+
Dynamic: classifier
|
|
16
|
+
Dynamic: description-content-type
|
|
17
|
+
Dynamic: home-page
|
|
18
|
+
Dynamic: requires-python
|
|
19
|
+
Dynamic: summary
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
packer
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Setup script for the Project Packer / Unpacker tool.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from setuptools import setup, find_packages
|
|
7
|
+
|
|
8
|
+
setup(
|
|
9
|
+
name="project-packer",
|
|
10
|
+
version="1.0.0",
|
|
11
|
+
description="Pack any Python project into a single file for AI sharing, respecting setup.py and MANIFEST.in",
|
|
12
|
+
long_description=open("README.md", "r", encoding="utf-8").read() if __import__("os").path.exists("README.md") else "",
|
|
13
|
+
long_description_content_type="text/markdown",
|
|
14
|
+
author="Udaya Raj Joshi",
|
|
15
|
+
author_email="udayarajjoshi@aol.com",
|
|
16
|
+
url="https://github.com/yourusername/project-packer",
|
|
17
|
+
py_modules=["packer"], # because it's a single module
|
|
18
|
+
entry_points={
|
|
19
|
+
"console_scripts": [
|
|
20
|
+
"packer = packer:main",
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
python_requires=">=3.8",
|
|
24
|
+
classifiers=[
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
],
|
|
29
|
+
)
|