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.
@@ -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,7 @@
1
+ packer.py
2
+ setup.py
3
+ project_packer.egg-info/PKG-INFO
4
+ project_packer.egg-info/SOURCES.txt
5
+ project_packer.egg-info/dependency_links.txt
6
+ project_packer.egg-info/entry_points.txt
7
+ project_packer.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ packer = packer:main
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )