project-enclave-tools 0.1.1__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,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: project-enclave-tools
3
+ Version: 0.1.1
4
+ Summary: Tools made mainly to help the devs of project enclave and other sub projects under it
5
+ Author-email: Pradhya <Pradhya@projectenclave.dev>
6
+ Project-URL: Repository, https://github.com/Chinglen2080/dev-tools
7
+ Project-URL: Issues, https://github.com/Chinglen2080/dev-tools/issues
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Dev Tools
12
+
13
+ Dev Tools is a small command-line toolkit for contributors to [Project Enclave](https://github.com/project-enclave). It currently provides signed SHA256 manifests for release files.
14
+
15
+ ## Installation
16
+
17
+ Install the current public release with `pipx`:
18
+
19
+ ```bash
20
+ pipx install "git+https://github.com/Chinglen2080/dev-tools.git@v0.1.1"
21
+ ```
22
+
23
+ OpenSSL must be installed and available on your `PATH` for key generation and signing.
24
+
25
+ ## Signing release files
26
+
27
+ Generate a 2048-bit RSA key pair the first time you use the signing tool:
28
+
29
+ ```bash
30
+ tools sign --init
31
+ ```
32
+
33
+ The keys are stored in `~/.config/dev-tools/keys/`. Keep `private.pem` private; distribute `public.pem` to anyone who needs to verify your releases. If keys already exist, initialization asks before replacing them.
34
+
35
+ From the release directory, create or update the manifest and signature:
36
+
37
+ ```bash
38
+ tools sign
39
+ ```
40
+
41
+ The manifest contains SHA256 hashes for `.py`, `.json`, `.yaml`, `.yml`, `.toml`, `.txt`, and `.md` files. Hidden files and common generated directories are skipped. Commit `manifest.json` and `manifest.sig` with the release.
42
+
43
+ To sign or verify a different directory, pass it with `--directory` (or `-C`):
44
+
45
+ ```bash
46
+ tools sign --directory ./release
47
+ tools sign --verify -C ./release
48
+ ```
49
+
50
+ Verification checks the signature, each listed file's hash, missing files, and any new signable files that are absent from the manifest.
51
+
52
+ ## License
53
+
54
+ This tool is provided under the GNU General Public License v3.0.
@@ -0,0 +1,44 @@
1
+ # Dev Tools
2
+
3
+ Dev Tools is a small command-line toolkit for contributors to [Project Enclave](https://github.com/project-enclave). It currently provides signed SHA256 manifests for release files.
4
+
5
+ ## Installation
6
+
7
+ Install the current public release with `pipx`:
8
+
9
+ ```bash
10
+ pipx install "git+https://github.com/Chinglen2080/dev-tools.git@v0.1.1"
11
+ ```
12
+
13
+ OpenSSL must be installed and available on your `PATH` for key generation and signing.
14
+
15
+ ## Signing release files
16
+
17
+ Generate a 2048-bit RSA key pair the first time you use the signing tool:
18
+
19
+ ```bash
20
+ tools sign --init
21
+ ```
22
+
23
+ The keys are stored in `~/.config/dev-tools/keys/`. Keep `private.pem` private; distribute `public.pem` to anyone who needs to verify your releases. If keys already exist, initialization asks before replacing them.
24
+
25
+ From the release directory, create or update the manifest and signature:
26
+
27
+ ```bash
28
+ tools sign
29
+ ```
30
+
31
+ The manifest contains SHA256 hashes for `.py`, `.json`, `.yaml`, `.yml`, `.toml`, `.txt`, and `.md` files. Hidden files and common generated directories are skipped. Commit `manifest.json` and `manifest.sig` with the release.
32
+
33
+ To sign or verify a different directory, pass it with `--directory` (or `-C`):
34
+
35
+ ```bash
36
+ tools sign --directory ./release
37
+ tools sign --verify -C ./release
38
+ ```
39
+
40
+ Verification checks the signature, each listed file's hash, missing files, and any new signable files that are absent from the manifest.
41
+
42
+ ## License
43
+
44
+ This tool is provided under the GNU General Public License v3.0.
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python3
2
+ """Command line interface for dev-tools."""
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+
7
+ from dev_tools.tools.signing import SigningError, init_keys, sign_files, verify_files
8
+
9
+
10
+ def build_parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser(
12
+ description="Developer tools toolkit",
13
+ prog="tools",
14
+ )
15
+ commands = parser.add_subparsers(dest="command", required=True)
16
+ sign_parser = commands.add_parser("sign", help="Sign or verify release files")
17
+ action = sign_parser.add_mutually_exclusive_group()
18
+ action.add_argument(
19
+ "--init",
20
+ action="store_true",
21
+ help="Generate a signing key pair",
22
+ )
23
+ action.add_argument(
24
+ "--verify",
25
+ action="store_true",
26
+ help="Verify the manifest and its files",
27
+ )
28
+ sign_parser.add_argument(
29
+ "-C",
30
+ "--directory",
31
+ type=Path,
32
+ default=Path.cwd(),
33
+ metavar="DIR",
34
+ help="Directory to sign or verify (default: current directory)",
35
+ )
36
+ return parser
37
+
38
+
39
+ def main() -> int:
40
+ parser = build_parser()
41
+ args = parser.parse_args()
42
+
43
+ try:
44
+ if args.command == "sign":
45
+ if args.init:
46
+ init_keys()
47
+ elif args.verify:
48
+ verify_files(args.directory)
49
+ else:
50
+ sign_files(args.directory)
51
+ except (SigningError, OSError) as exc:
52
+ parser.exit(1, f"ERROR: {exc}\n")
53
+
54
+ return 0
55
+
56
+
57
+ if __name__ == "__main__":
58
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ """Developer tools toolkit."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Tools package."""
@@ -0,0 +1,271 @@
1
+ """Create and verify signed SHA256 manifests for release files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path, PurePosixPath, PureWindowsPath
12
+ from typing import Dict, List, Optional
13
+
14
+ from dev_tools.utils.keys import (
15
+ generate_keys,
16
+ get_private_key,
17
+ get_public_key,
18
+ keys_exist,
19
+ )
20
+
21
+
22
+ class SigningError(Exception):
23
+ """An expected signing or verification failure."""
24
+
25
+
26
+ # Directories to exclude from scanning.
27
+ EXCLUDE_DIRS = {
28
+ ".git", ".venv", "venv", ".env", "__pycache__",
29
+ ".mypy_cache", ".pytest_cache", "node_modules",
30
+ "dist", "build", ".tox",
31
+ }
32
+
33
+ # File extensions to sign.
34
+ SIGN_EXTENSIONS = {".py", ".json", ".yaml", ".yml", ".toml", ".txt", ".md"}
35
+ HASH_RE = re.compile(r"^[0-9a-f]{64}$")
36
+
37
+
38
+ def hash_file(path: Path) -> str:
39
+ """Compute a SHA256 hash without loading the entire file into memory."""
40
+ digest = hashlib.sha256()
41
+ with path.open("rb") as file_obj:
42
+ for chunk in iter(lambda: file_obj.read(1024 * 1024), b""):
43
+ digest.update(chunk)
44
+ return digest.hexdigest()
45
+
46
+
47
+ def discover_files(root: Path) -> List[Path]:
48
+ """Find signable regular files, returning paths relative to ``root`` sorted."""
49
+ root = Path(root)
50
+ found: List[Path] = []
51
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
52
+ dirnames[:] = sorted(
53
+ dirname
54
+ for dirname in dirnames
55
+ if dirname not in EXCLUDE_DIRS
56
+ and not dirname.startswith(".")
57
+ and not dirname.endswith(".egg-info")
58
+ and not (Path(dirpath) / dirname).is_symlink()
59
+ )
60
+
61
+ for filename in sorted(filenames):
62
+ if filename in ("manifest.json", "manifest.sig") or filename.startswith("."):
63
+ continue
64
+ candidate = Path(dirpath) / filename
65
+ if (
66
+ candidate.suffix.lower() in SIGN_EXTENSIONS
67
+ and candidate.is_file()
68
+ and not candidate.is_symlink()
69
+ ):
70
+ found.append(candidate)
71
+
72
+ return sorted(found, key=lambda item: item.relative_to(root).as_posix())
73
+
74
+
75
+ def _run_openssl(args: List[str], operation: str) -> subprocess.CompletedProcess:
76
+ try:
77
+ result = subprocess.run(args, capture_output=True, text=True, check=False)
78
+ except FileNotFoundError as exc:
79
+ raise SigningError("OpenSSL is required but was not found on PATH.") from exc
80
+ if result.returncode != 0:
81
+ detail = result.stderr.strip() or result.stdout.strip() or "OpenSSL failed."
82
+ raise SigningError(f"{operation} failed: {detail}")
83
+ return result
84
+
85
+
86
+ def sign_manifest(manifest_path: Path, sig_path: Path, private_key: Path) -> None:
87
+ """Sign a manifest using the configured private key."""
88
+ _run_openssl(
89
+ [
90
+ "openssl", "dgst", "-sha256", "-sign", str(private_key),
91
+ "-out", str(sig_path), str(manifest_path),
92
+ ],
93
+ "Signing the manifest",
94
+ )
95
+
96
+
97
+ def verify_manifest(manifest_path: Path, sig_path: Path, public_key: Path) -> bool:
98
+ """Return whether the signature matches the manifest and public key."""
99
+ try:
100
+ result = subprocess.run(
101
+ [
102
+ "openssl", "dgst", "-sha256", "-verify", str(public_key),
103
+ "-signature", str(sig_path), str(manifest_path),
104
+ ],
105
+ capture_output=True,
106
+ text=True,
107
+ check=False,
108
+ )
109
+ except FileNotFoundError as exc:
110
+ raise SigningError("OpenSSL is required but was not found on PATH.") from exc
111
+ return result.returncode == 0
112
+
113
+
114
+ def init_keys() -> None:
115
+ """Initialize signing keys (first-time setup)."""
116
+ try:
117
+ generate_keys()
118
+ except RuntimeError as exc:
119
+ raise SigningError(str(exc)) from exc
120
+
121
+
122
+ def _release_directory(cwd: Optional[Path]) -> Path:
123
+ directory = Path.cwd() if cwd is None else Path(cwd)
124
+ directory = directory.expanduser().resolve()
125
+ if not directory.is_dir():
126
+ raise SigningError(f"Not a directory: {directory}")
127
+ return directory
128
+
129
+
130
+ def sign_files(cwd: Optional[Path] = None) -> None:
131
+ """Sign all signable files in a directory and write its manifest."""
132
+ cwd = _release_directory(cwd)
133
+
134
+ if not keys_exist():
135
+ raise SigningError("Signing keys not found. Run 'tools sign --init' first.")
136
+
137
+ private_key = get_private_key()
138
+ public_key = get_public_key()
139
+ print(f"\nScanning {cwd} for signable files...")
140
+ files = discover_files(cwd)
141
+ if not files:
142
+ print("No signable files found.")
143
+ return
144
+ print(f"Found {len(files)} file(s) to sign.\n")
145
+
146
+ manifest: Dict[str, str] = {}
147
+ for file_path in files:
148
+ relative_path = file_path.relative_to(cwd).as_posix()
149
+ try:
150
+ manifest[relative_path] = hash_file(file_path)
151
+ print(f" ✓ {relative_path}")
152
+ except OSError as exc:
153
+ raise SigningError(f"Could not read {relative_path}: {exc}") from exc
154
+
155
+ manifest_path = cwd / "manifest.json"
156
+ sig_path = cwd / "manifest.sig"
157
+
158
+ # Stage both artifacts first so a failed signing operation leaves the old
159
+ # release files untouched.
160
+ with tempfile.TemporaryDirectory(prefix=".dev-tools-", dir=str(cwd)) as temp_dir:
161
+ staged_manifest = Path(temp_dir) / "manifest.json"
162
+ staged_signature = Path(temp_dir) / "manifest.sig"
163
+ staged_manifest.write_text(
164
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
165
+ encoding="utf-8",
166
+ )
167
+ sign_manifest(staged_manifest, staged_signature, private_key)
168
+
169
+ print("\nVerifying signature...")
170
+ if not verify_manifest(staged_manifest, staged_signature, public_key):
171
+ raise SigningError("Signature verification failed; release files were not updated.")
172
+
173
+ os.replace(staged_manifest, manifest_path)
174
+ os.replace(staged_signature, sig_path)
175
+
176
+ print(f"✓ Wrote manifest.json ({len(manifest)} files)")
177
+ print("✓ Signed manifest.sig")
178
+ print("✓ Signature verified ✅\n")
179
+ print("Release ready — commit manifest.json and manifest.sig")
180
+
181
+
182
+ def _manifest_file(root: Path, relative_path: str) -> Path:
183
+ """Resolve a canonical manifest path and ensure it stays inside the release."""
184
+ posix_path = PurePosixPath(relative_path)
185
+ windows_path = PureWindowsPath(relative_path)
186
+ if (
187
+ not relative_path
188
+ or "\\" in relative_path
189
+ or posix_path.is_absolute()
190
+ or windows_path.is_absolute()
191
+ or windows_path.drive
192
+ or posix_path.as_posix() != relative_path
193
+ or not posix_path.parts
194
+ or any(part in (".", "..") for part in posix_path.parts)
195
+ ):
196
+ raise SigningError(f"Invalid path in manifest: {relative_path!r}")
197
+
198
+ candidate = root.joinpath(*posix_path.parts)
199
+ resolved_root = root.resolve()
200
+ try:
201
+ resolved_candidate = candidate.resolve()
202
+ except OSError as exc:
203
+ raise SigningError(f"Could not resolve manifest path: {relative_path}") from exc
204
+ try:
205
+ resolved_candidate.relative_to(resolved_root)
206
+ except ValueError as exc:
207
+ raise SigningError(f"Manifest path escapes the release directory: {relative_path}") from exc
208
+ return candidate
209
+
210
+
211
+ def verify_files(cwd: Optional[Path] = None) -> None:
212
+ """Verify the manifest signature and every signable file in a directory."""
213
+ cwd = _release_directory(cwd)
214
+ manifest_path = cwd / "manifest.json"
215
+ sig_path = cwd / "manifest.sig"
216
+
217
+ if not manifest_path.is_file() or not sig_path.is_file():
218
+ raise SigningError(f"manifest.json or manifest.sig not found in {cwd}")
219
+
220
+ public_key = get_public_key()
221
+ if not public_key.is_file():
222
+ raise SigningError("Public key not found. Run 'tools sign --init' first.")
223
+
224
+ print(f"Verifying signatures in {cwd}...")
225
+ if not verify_manifest(manifest_path, sig_path, public_key):
226
+ raise SigningError("Manifest signature verification failed.")
227
+ print("✓ Signature verified ✅")
228
+
229
+ try:
230
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
231
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
232
+ raise SigningError(f"Could not read manifest.json: {exc}") from exc
233
+ if not isinstance(manifest, dict):
234
+ raise SigningError("manifest.json must contain an object mapping file paths to SHA256 hashes.")
235
+
236
+ for file_path, expected_hash in manifest.items():
237
+ if not isinstance(file_path, str) or not isinstance(expected_hash, str):
238
+ raise SigningError("Manifest paths and hashes must be strings.")
239
+ if not HASH_RE.fullmatch(expected_hash):
240
+ raise SigningError(f"Invalid SHA256 hash in manifest for {file_path!r}.")
241
+
242
+ print(f"\nChecking {len(manifest)} files...")
243
+ all_match = True
244
+ for relative_path, expected_hash in sorted(manifest.items()):
245
+ file_path = _manifest_file(cwd, relative_path)
246
+ if not file_path.is_file():
247
+ print(f" ✗ Missing: {relative_path}")
248
+ all_match = False
249
+ continue
250
+ try:
251
+ actual_hash = hash_file(file_path)
252
+ except OSError as exc:
253
+ print(f" ✗ Could not read {relative_path}: {exc}")
254
+ all_match = False
255
+ continue
256
+ if actual_hash == expected_hash:
257
+ print(f" ✓ {relative_path}")
258
+ else:
259
+ print(f" ✗ MODIFIED: {relative_path}")
260
+ all_match = False
261
+
262
+ listed_paths = set(manifest)
263
+ for file_path in discover_files(cwd):
264
+ relative_path = file_path.relative_to(cwd).as_posix()
265
+ if relative_path not in listed_paths:
266
+ print(f" ✗ UNLISTED: {relative_path}")
267
+ all_match = False
268
+
269
+ if not all_match:
270
+ raise SigningError("Some files are missing, changed, or absent from the signed manifest.")
271
+ print("\n✓ All files verified ✅")
@@ -0,0 +1 @@
1
+ """Utilities package."""
@@ -0,0 +1,81 @@
1
+ """Key management utilities for the signing tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+
11
+ def get_keys_dir(create: bool = False) -> Path:
12
+ """Return the key directory, creating it only when requested."""
13
+ keys_dir = Path.home() / ".config" / "dev-tools" / "keys"
14
+ if create:
15
+ keys_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
16
+ return keys_dir
17
+
18
+
19
+ def get_private_key() -> Path:
20
+ """Get the path to the private key."""
21
+ return get_keys_dir() / "private.pem"
22
+
23
+
24
+ def get_public_key() -> Path:
25
+ """Get the path to the public key."""
26
+ return get_keys_dir() / "public.pem"
27
+
28
+
29
+ def keys_exist() -> bool:
30
+ """Check if both keys exist without creating the key directory."""
31
+ return get_private_key().is_file() and get_public_key().is_file()
32
+
33
+
34
+ def _run_openssl(args: list[str], description: str) -> None:
35
+ try:
36
+ result = subprocess.run(args, capture_output=True, text=True, check=False)
37
+ except FileNotFoundError as exc:
38
+ raise RuntimeError("OpenSSL is required but was not found on PATH.") from exc
39
+ if result.returncode != 0:
40
+ detail = result.stderr.strip() or result.stdout.strip() or "OpenSSL failed."
41
+ raise RuntimeError(f"{description}: {detail}")
42
+
43
+
44
+ def generate_keys() -> None:
45
+ """Generate an RSA key pair, preserving existing files on failure."""
46
+ keys_dir = get_keys_dir(create=True)
47
+ private_key = get_private_key()
48
+ public_key = get_public_key()
49
+
50
+ if private_key.exists() or public_key.exists():
51
+ print("Keys already exist at:")
52
+ print(f" Private: {private_key}")
53
+ print(f" Public: {public_key}")
54
+ response = input("Overwrite? (y/n): ").strip().lower()
55
+ if response != "y":
56
+ print("Aborted.")
57
+ return
58
+
59
+ print(f"Generating RSA keypair in {keys_dir}...")
60
+ with tempfile.TemporaryDirectory(prefix=".keygen-", dir=str(keys_dir)) as temp_dir:
61
+ staged_private = Path(temp_dir) / "private.pem"
62
+ staged_public = Path(temp_dir) / "public.pem"
63
+ _run_openssl(
64
+ ["openssl", "genrsa", "-out", str(staged_private), "2048"],
65
+ "Could not generate private key",
66
+ )
67
+ _run_openssl(
68
+ [
69
+ "openssl", "rsa", "-in", str(staged_private), "-pubout",
70
+ "-out", str(staged_public),
71
+ ],
72
+ "Could not generate public key",
73
+ )
74
+ staged_private.chmod(0o600)
75
+ staged_public.chmod(0o644)
76
+ os.replace(staged_private, private_key)
77
+ os.replace(staged_public, public_key)
78
+
79
+ print("✓ Keys generated successfully!")
80
+ print(f" Private key: {private_key}")
81
+ print(f" Public key: {public_key}")
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: project-enclave-tools
3
+ Version: 0.1.1
4
+ Summary: Tools made mainly to help the devs of project enclave and other sub projects under it
5
+ Author-email: Pradhya <Pradhya@projectenclave.dev>
6
+ Project-URL: Repository, https://github.com/Chinglen2080/dev-tools
7
+ Project-URL: Issues, https://github.com/Chinglen2080/dev-tools/issues
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Dev Tools
12
+
13
+ Dev Tools is a small command-line toolkit for contributors to [Project Enclave](https://github.com/project-enclave). It currently provides signed SHA256 manifests for release files.
14
+
15
+ ## Installation
16
+
17
+ Install the current public release with `pipx`:
18
+
19
+ ```bash
20
+ pipx install "git+https://github.com/Chinglen2080/dev-tools.git@v0.1.1"
21
+ ```
22
+
23
+ OpenSSL must be installed and available on your `PATH` for key generation and signing.
24
+
25
+ ## Signing release files
26
+
27
+ Generate a 2048-bit RSA key pair the first time you use the signing tool:
28
+
29
+ ```bash
30
+ tools sign --init
31
+ ```
32
+
33
+ The keys are stored in `~/.config/dev-tools/keys/`. Keep `private.pem` private; distribute `public.pem` to anyone who needs to verify your releases. If keys already exist, initialization asks before replacing them.
34
+
35
+ From the release directory, create or update the manifest and signature:
36
+
37
+ ```bash
38
+ tools sign
39
+ ```
40
+
41
+ The manifest contains SHA256 hashes for `.py`, `.json`, `.yaml`, `.yml`, `.toml`, `.txt`, and `.md` files. Hidden files and common generated directories are skipped. Commit `manifest.json` and `manifest.sig` with the release.
42
+
43
+ To sign or verify a different directory, pass it with `--directory` (or `-C`):
44
+
45
+ ```bash
46
+ tools sign --directory ./release
47
+ tools sign --verify -C ./release
48
+ ```
49
+
50
+ Verification checks the signature, each listed file's hash, missing files, and any new signable files that are absent from the manifest.
51
+
52
+ ## License
53
+
54
+ This tool is provided under the GNU General Public License v3.0.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ dev_tools/cli.py
4
+ dev_tools/init.py
5
+ dev_tools/tools/init.py
6
+ dev_tools/tools/signing.py
7
+ dev_tools/utils/init.py
8
+ dev_tools/utils/keys.py
9
+ project_enclave_tools.egg-info/PKG-INFO
10
+ project_enclave_tools.egg-info/SOURCES.txt
11
+ project_enclave_tools.egg-info/dependency_links.txt
12
+ project_enclave_tools.egg-info/entry_points.txt
13
+ project_enclave_tools.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tools = dev_tools.cli:main
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "project-enclave-tools"
7
+ version = "0.1.1"
8
+ description = "Tools made mainly to help the devs of project enclave and other sub projects under it"
9
+ authors = [{name = "Pradhya", email = "Pradhya@projectenclave.dev"}]
10
+ readme = "README.md"
11
+ requires-python = ">=3.8"
12
+ dependencies = []
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/Chinglen2080/dev-tools"
16
+ Issues = "https://github.com/Chinglen2080/dev-tools/issues"
17
+
18
+ [project.scripts]
19
+ tools = "dev_tools.cli:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+