Add bundle-zip skill
This commit is contained in:
parent
86d860044b
commit
287ef86eca
|
|
@ -21,12 +21,14 @@ This repository includes install scripts for copying reviewed Skill source into
|
|||
PowerShell:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations
|
||||
```
|
||||
|
||||
Use `-Force` to back up and replace an existing installed copy:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip -Force
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations -Force
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -47,20 +47,33 @@ conda activate C:\Users\wangq\.conda\envs\skills-vault
|
|||
|
||||
Most lightweight Skills should use this shared environment. Create a dedicated environment only when a Skill has conflicting or heavy dependencies.
|
||||
|
||||
The shared environment includes `pyyaml` so Skill metadata validation can also run inside the repository environment. Do not fall back to bare `python` for validation just because the base Miniconda environment happens to have extra packages.
|
||||
|
||||
When running Python from automation, prefer `conda run` so the intended environment is explicit even if the shell has not been activated:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault python -m unittest discover -s skills\fix-title\tests -v
|
||||
conda run -n skills-vault python .\skills\fix-title\scripts\fix_markdown_titles.py --help
|
||||
conda run -n skills-vault python -B -m unittest discover -s skills\fix-title\tests -v
|
||||
conda run -n skills-vault python -B .\skills\fix-title\scripts\fix_markdown_titles.py --help
|
||||
conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills\bundle-zip
|
||||
```
|
||||
|
||||
Do not rely on bare `python` unless you have first verified it resolves to `C:\Users\wangq\.conda\envs\skills-vault\python.exe`.
|
||||
|
||||
If pytest cannot access the default Windows temp directory, point `TMP` and `TEMP` at a repository-local ignored directory before running tests:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path tmp\pytest | Out-Null
|
||||
$env:TMP = (Resolve-Path -LiteralPath tmp\pytest).Path
|
||||
$env:TEMP = $env:TMP
|
||||
conda run -n skills-vault pytest skills\voice-generation\tests -q
|
||||
```
|
||||
|
||||
## Install One Skill
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill fix-title
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations
|
||||
```
|
||||
|
|
@ -68,6 +81,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repa
|
|||
Git Bash / Linux / macOS:
|
||||
|
||||
```bash
|
||||
bash scripts/install-skill.sh bundle-zip
|
||||
bash scripts/install-skill.sh fix-title
|
||||
```
|
||||
|
||||
|
|
@ -101,11 +115,13 @@ By default, install scripts refuse to overwrite an existing Skill.
|
|||
Use `-Force` or `--force` to back up the current installed directory and copy the repository version:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip -Force
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill fix-title -Force
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations -Force
|
||||
```
|
||||
|
||||
```bash
|
||||
bash scripts/install-skill.sh bundle-zip --force
|
||||
bash scripts/install-skill.sh fix-title --force
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,14 @@ If the source is CCPE content, do not migrate it.
|
|||
|
||||
5. Add or repair tests when practical.
|
||||
|
||||
6. Register the Skill in:
|
||||
6. Validate tests and Skill metadata through the shared environment:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault python -B -m unittest discover -s skills\<skill-name>\tests -v
|
||||
conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills\<skill-name>
|
||||
```
|
||||
|
||||
7. Register the Skill in:
|
||||
|
||||
```text
|
||||
registry/skills-index.md
|
||||
|
|
@ -52,13 +59,13 @@ If the source is CCPE content, do not migrate it.
|
|||
|
||||
Register only real migrated or maintained Skill source. Do not use the registry as a backlog for speculative candidates.
|
||||
|
||||
7. If needed, install or sync the Skill into:
|
||||
8. If needed, install or sync the Skill into:
|
||||
|
||||
```text
|
||||
C:\Users\wangq\.agents\skills\<skill-name>
|
||||
```
|
||||
|
||||
8. Commit the migration.
|
||||
9. Commit the migration.
|
||||
|
||||
## Migration Status Values
|
||||
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ channels:
|
|||
dependencies:
|
||||
- python=3.11
|
||||
- pip
|
||||
- pyyaml
|
||||
- pytest>=7.0
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Existing CCPE content is not migrated here.
|
|||
|
||||
| Skill | Purpose | Source | Status | Install Policy | Installed Path | CCPE Registration |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `bundle-zip` | Create zip archives from explicit file lists while preserving source-relative paths and validating entries by readback. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\bundle-zip` | `installed` | `default` | `C:\Users\wangq\.agents\skills\bundle-zip` | none |
|
||||
| `fix-title` | Shift Markdown ATX heading depth for pasted LLM replies before insertion under parent sections. | `C:\Users\wangq\Documents\Codex\knowledge-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none |
|
||||
| `repair-markdown-citations` | Repair ChatGPT/Deep Research Markdown citation tokens into standard footnotes and a deduplicated reference section using exact turn-ID metadata mapping. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\repair-markdown-citations` | `installed` | `default` | `C:\Users\wangq\.agents\skills\repair-markdown-citations` | none |
|
||||
| `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `D:\AI\ClaudeCode\voice-ge` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none |
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ skills/<skill-name>/scripts/
|
|||
Current install scripts:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill fix-title
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -All
|
||||
```
|
||||
|
||||
```bash
|
||||
bash scripts/install-skill.sh bundle-zip
|
||||
bash scripts/install-skill.sh fix-title
|
||||
bash scripts/install-skill.sh --all
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Bundle Zip
|
||||
|
||||
Source Skill for creating zip archives from explicit file lists while preserving source-relative paths.
|
||||
|
||||
The canonical entry point is `SKILL.md`; the deterministic packager is in `scripts/bundle_zip.py`.
|
||||
|
||||
Run Python code through the shared repository environment:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault python .\scripts\bundle_zip.py --file-list "C:\path\files.txt" --relative-root-marker repo-name --output "C:\path\bundle.zip" --verify --json
|
||||
```
|
||||
|
||||
Validate from the repository root with the same environment:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault python -B -m unittest discover -s skills\bundle-zip\tests -v
|
||||
conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills\bundle-zip
|
||||
```
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
name: bundle-zip
|
||||
description: Use when Codex needs to create a zip archive from an explicit file list while preserving source-relative paths, especially for GPT/CCRA review bundles, handoff packages, changed-file bundles, or Windows-safe packaging where PowerShell Compress-Archive may flatten paths. Use for newline-delimited file lists, root-marker-based zip entry names, overwrite-safe zip creation, and zip readback validation.
|
||||
---
|
||||
|
||||
# Bundle Zip
|
||||
|
||||
## Purpose
|
||||
|
||||
Create a zip archive from an explicit newline-delimited file list while preserving paths relative to a named root path segment. Use the bundled Python script instead of PowerShell `Compress-Archive` when exact zip entry names matter.
|
||||
|
||||
The script uses Python standard-library `zipfile`; it does not implement compression itself.
|
||||
|
||||
## Command
|
||||
|
||||
Run from the repository or working directory that should resolve relative file-list entries:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault python .\scripts\bundle_zip.py `
|
||||
--file-list C:\path\files.txt `
|
||||
--relative-root-marker repo-name `
|
||||
--output C:\path\bundle.zip `
|
||||
--verify `
|
||||
--json
|
||||
```
|
||||
|
||||
If the Skill is installed under `C:\Users\wangq\.agents\skills\bundle-zip`, use that installed script path or run from the installed Skill directory.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `--file-list`: UTF-8 text file with one source file per line. Blank lines are ignored.
|
||||
- `--relative-root-marker`: exact path segment that defines where zip entry names start. If a source path contains the marker zero times or more than once, fail.
|
||||
- `--output`: destination zip path. The parent directory must already exist.
|
||||
- `--verify`: accepted for explicit workflows; readback verification is always performed.
|
||||
- `--json`: print machine-readable summary JSON to stdout.
|
||||
- `--json-output`: optionally write the same validation summary to a JSON file.
|
||||
|
||||
File-list entries may be absolute paths or paths relative to the current working directory. Zip entry names always use `/`.
|
||||
|
||||
## Safety
|
||||
|
||||
- Validate all sources before replacing an existing output zip.
|
||||
- Reject missing files, directories, duplicate zip entries, and an output path that is also a source file.
|
||||
- Package only explicitly listed files; do not recursively package directories.
|
||||
- Write a temporary zip next to the output, verify it, then replace the requested output zip.
|
||||
- Do not modify source files.
|
||||
|
||||
## Validation
|
||||
|
||||
After editing the script, run:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault python -B -m unittest discover -s skills/bundle-zip/tests -v
|
||||
```
|
||||
|
||||
For full repository confidence, also run relevant neighboring Skill tests when shared code or install behavior changes.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
interface:
|
||||
display_name: "Bundle Zip"
|
||||
short_description: "Create source-relative zip bundles with validation."
|
||||
default_prompt: "Use $bundle-zip to create a source-relative zip from this file list and verify the entries."
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Fixtures
|
||||
|
||||
This directory is reserved for future bundle-zip fixtures.
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceEntry:
|
||||
source: pathlib.Path
|
||||
entry_name: str
|
||||
|
||||
|
||||
class BundleZipError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_part(value: str) -> str:
|
||||
if os.name == "nt":
|
||||
return value.casefold()
|
||||
return value
|
||||
|
||||
|
||||
def read_file_list(file_list: pathlib.Path) -> list[str]:
|
||||
if not file_list.exists():
|
||||
raise BundleZipError(f"File list does not exist: {file_list}")
|
||||
if not file_list.is_file():
|
||||
raise BundleZipError(f"File list is not a file: {file_list}")
|
||||
|
||||
lines = file_list.read_text(encoding="utf-8").splitlines()
|
||||
return [line.strip() for line in lines if line.strip()]
|
||||
|
||||
|
||||
def resolve_input_path(value: str, cwd: pathlib.Path) -> pathlib.Path:
|
||||
path = pathlib.Path(value).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = cwd / path
|
||||
return path.resolve(strict=False)
|
||||
|
||||
|
||||
def entry_name_for(path: pathlib.Path, marker: str) -> str:
|
||||
normalized_marker = normalize_part(marker)
|
||||
matches = [
|
||||
index
|
||||
for index, part in enumerate(path.parts)
|
||||
if normalize_part(part) == normalized_marker
|
||||
]
|
||||
if not matches:
|
||||
raise BundleZipError(
|
||||
f"Source file is not under root marker '{marker}': {path}"
|
||||
)
|
||||
if len(matches) > 1:
|
||||
raise BundleZipError(
|
||||
f"Source file contains root marker '{marker}' more than once: {path}"
|
||||
)
|
||||
|
||||
relative_parts = path.parts[matches[0] + 1 :]
|
||||
if not relative_parts:
|
||||
raise BundleZipError(
|
||||
f"Source file cannot be made relative to root marker '{marker}': {path}"
|
||||
)
|
||||
return "/".join(relative_parts)
|
||||
|
||||
|
||||
def validate_sources(
|
||||
file_values: Iterable[str],
|
||||
*,
|
||||
marker: str,
|
||||
output: pathlib.Path,
|
||||
cwd: pathlib.Path,
|
||||
) -> list[SourceEntry]:
|
||||
if not marker:
|
||||
raise BundleZipError("Relative root marker must not be empty")
|
||||
|
||||
output = output.resolve(strict=False)
|
||||
entries: list[SourceEntry] = []
|
||||
seen: dict[str, pathlib.Path] = {}
|
||||
|
||||
for value in file_values:
|
||||
source = resolve_input_path(value, cwd)
|
||||
if not source.exists():
|
||||
raise BundleZipError(f"Source file does not exist: {source}")
|
||||
if not source.is_file():
|
||||
raise BundleZipError(f"Source path is not a file: {source}")
|
||||
if source == output:
|
||||
raise BundleZipError(f"Output path is the same as a source file: {source}")
|
||||
|
||||
entry_name = entry_name_for(source, marker)
|
||||
if entry_name in seen:
|
||||
raise BundleZipError(
|
||||
"Duplicate zip entry produced: "
|
||||
f"{entry_name} from {seen[entry_name]} and {source}"
|
||||
)
|
||||
seen[entry_name] = source
|
||||
entries.append(SourceEntry(source=source, entry_name=entry_name))
|
||||
|
||||
if not entries:
|
||||
raise BundleZipError("File list did not contain any source files")
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def zip_entries(path: pathlib.Path) -> list[str]:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
return archive.namelist()
|
||||
|
||||
|
||||
def write_zip(entries: list[SourceEntry], output: pathlib.Path) -> list[str]:
|
||||
output_parent = output.parent.resolve(strict=False)
|
||||
if not output_parent.exists():
|
||||
raise BundleZipError(f"Output parent directory does not exist: {output_parent}")
|
||||
if not output_parent.is_dir():
|
||||
raise BundleZipError(f"Output parent path is not a directory: {output_parent}")
|
||||
|
||||
expected_entries = [entry.entry_name for entry in entries]
|
||||
temp_name = ""
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix=f".{output.name}.",
|
||||
suffix=".tmp",
|
||||
dir=output_parent,
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_name = temp_file.name
|
||||
|
||||
temp_path = pathlib.Path(temp_name)
|
||||
with zipfile.ZipFile(
|
||||
temp_path,
|
||||
"w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6,
|
||||
) as archive:
|
||||
for entry in entries:
|
||||
archive.write(entry.source, arcname=entry.entry_name)
|
||||
|
||||
actual_entries = zip_entries(temp_path)
|
||||
if actual_entries != expected_entries:
|
||||
raise BundleZipError(
|
||||
"Zip verification failed: "
|
||||
f"expected {expected_entries}, got {actual_entries}"
|
||||
)
|
||||
|
||||
os.replace(temp_path, output)
|
||||
final_entries = zip_entries(output)
|
||||
if final_entries != expected_entries:
|
||||
raise BundleZipError(
|
||||
"Final zip verification failed: "
|
||||
f"expected {expected_entries}, got {final_entries}"
|
||||
)
|
||||
return final_entries
|
||||
finally:
|
||||
if temp_name:
|
||||
temp_path = pathlib.Path(temp_name)
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
def build_summary(
|
||||
*,
|
||||
status: str,
|
||||
output: pathlib.Path,
|
||||
source_file_count: int,
|
||||
entries: list[str],
|
||||
warnings: list[str] | None = None,
|
||||
errors: list[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"status": status,
|
||||
"output_zip": str(output.resolve(strict=False)),
|
||||
"source_file_count": source_file_count,
|
||||
"zip_entry_count": len(entries),
|
||||
"entries": entries,
|
||||
"warnings": warnings or [],
|
||||
"errors": errors or [],
|
||||
}
|
||||
|
||||
|
||||
def print_summary(summary: dict[str, object], *, as_json: bool) -> None:
|
||||
if as_json:
|
||||
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
||||
return
|
||||
|
||||
print(summary["status"])
|
||||
print(f"output_zip: {summary['output_zip']}")
|
||||
print(f"source_file_count: {summary['source_file_count']}")
|
||||
print(f"zip_entry_count: {summary['zip_entry_count']}")
|
||||
errors = summary.get("errors") or []
|
||||
warnings = summary.get("warnings") or []
|
||||
if warnings:
|
||||
print("warnings:")
|
||||
for warning in warnings:
|
||||
print(f"- {warning}")
|
||||
if errors:
|
||||
print("errors:")
|
||||
for error in errors:
|
||||
print(f"- {error}")
|
||||
entries = summary.get("entries") or []
|
||||
if entries:
|
||||
print("entries:")
|
||||
for entry in entries:
|
||||
print(f"- {entry}")
|
||||
|
||||
|
||||
def write_json_output(path: pathlib.Path, summary: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create a source-relative zip from a newline-delimited file list.",
|
||||
)
|
||||
parser.add_argument("--file-list", required=True, help="Newline-delimited file list.")
|
||||
parser.add_argument(
|
||||
"--relative-root-marker",
|
||||
required=True,
|
||||
help="Path segment used as the root for zip entry names.",
|
||||
)
|
||||
parser.add_argument("--output", required=True, help="Destination zip path.")
|
||||
parser.add_argument(
|
||||
"--verify",
|
||||
action="store_true",
|
||||
help="Accepted for explicit workflows; verification is always performed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print the validation summary as JSON.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json-output",
|
||||
help="Optional path to write the validation summary JSON.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv or sys.argv[1:])
|
||||
cwd = pathlib.Path.cwd().resolve(strict=False)
|
||||
file_list_path = resolve_input_path(args.file_list, cwd)
|
||||
output = resolve_input_path(args.output, cwd)
|
||||
file_values: list[str] = []
|
||||
|
||||
try:
|
||||
file_values = read_file_list(file_list_path)
|
||||
source_entries = validate_sources(
|
||||
file_values,
|
||||
marker=args.relative_root_marker,
|
||||
output=output,
|
||||
cwd=cwd,
|
||||
)
|
||||
entries = write_zip(source_entries, output)
|
||||
summary = build_summary(
|
||||
status="PASS",
|
||||
output=output,
|
||||
source_file_count=len(source_entries),
|
||||
entries=entries,
|
||||
)
|
||||
exit_code = 0
|
||||
except BundleZipError as error:
|
||||
summary = build_summary(
|
||||
status="FAIL",
|
||||
output=output,
|
||||
source_file_count=len(file_values),
|
||||
entries=[],
|
||||
errors=[str(error)],
|
||||
)
|
||||
exit_code = 1
|
||||
|
||||
if args.json_output:
|
||||
write_json_output(resolve_input_path(args.json_output, cwd), summary)
|
||||
print_summary(summary, as_json=args.json)
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "bundle_zip.py"
|
||||
|
||||
|
||||
def run_cli(*args: str, cwd: pathlib.Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
cwd=str(cwd) if cwd else None,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def write_file(path: pathlib.Path, text: str = "content") -> pathlib.Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def write_file_list(path: pathlib.Path, files: list[str]) -> pathlib.Path:
|
||||
path.write_text("\n".join(files) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class BundleZipTest(unittest.TestCase):
|
||||
def test_absolute_files_under_marker_create_source_relative_entries(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp) / "the-mindscape-of-bro-tsong"
|
||||
first = write_file(root / "cards" / "card_index.md", "card")
|
||||
second = write_file(root / "cards" / "intellectual_archaeology.md", "ia")
|
||||
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(first), str(second)])
|
||||
output = pathlib.Path(tmp) / "bundle.zip"
|
||||
|
||||
result = run_cli(
|
||||
"--file-list",
|
||||
str(file_list),
|
||||
"--relative-root-marker",
|
||||
"the-mindscape-of-bro-tsong",
|
||||
"--output",
|
||||
str(output),
|
||||
"--verify",
|
||||
"--json",
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
summary = json.loads(result.stdout)
|
||||
self.assertEqual(summary["status"], "PASS")
|
||||
self.assertEqual(
|
||||
summary["entries"],
|
||||
["cards/card_index.md", "cards/intellectual_archaeology.md"],
|
||||
)
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
self.assertEqual(archive.namelist(), summary["entries"])
|
||||
|
||||
def test_mixed_absolute_and_cwd_relative_files_create_correct_entries(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp) / "repo"
|
||||
absolute_file = write_file(root / "cards" / "absolute.md")
|
||||
relative_file = write_file(root / "notes" / "relative.md")
|
||||
file_list = write_file_list(
|
||||
root / "files.txt",
|
||||
[str(absolute_file), r"notes\relative.md"],
|
||||
)
|
||||
output = pathlib.Path(tmp) / "mixed.zip"
|
||||
|
||||
result = run_cli(
|
||||
"--file-list",
|
||||
str(file_list),
|
||||
"--relative-root-marker",
|
||||
"repo",
|
||||
"--output",
|
||||
str(output),
|
||||
"--verify",
|
||||
"--json",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
summary = json.loads(result.stdout)
|
||||
self.assertEqual(summary["entries"], ["cards/absolute.md", "notes/relative.md"])
|
||||
|
||||
def test_existing_destination_zip_is_replaced_after_validation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp) / "repo"
|
||||
source = write_file(root / "src" / "new.md", "new")
|
||||
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(source)])
|
||||
output = pathlib.Path(tmp) / "bundle.zip"
|
||||
with zipfile.ZipFile(output, "w") as archive:
|
||||
archive.writestr("old.txt", "old")
|
||||
|
||||
result = run_cli(
|
||||
"--file-list",
|
||||
str(file_list),
|
||||
"--relative-root-marker",
|
||||
"repo",
|
||||
"--output",
|
||||
str(output),
|
||||
"--verify",
|
||||
"--json",
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
self.assertEqual(archive.namelist(), ["src/new.md"])
|
||||
self.assertEqual(archive.read("src/new.md"), b"new")
|
||||
|
||||
def test_missing_source_fails_before_existing_output_is_touched(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp) / "repo"
|
||||
missing = root / "missing.md"
|
||||
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(missing)])
|
||||
output = pathlib.Path(tmp) / "bundle.zip"
|
||||
with zipfile.ZipFile(output, "w") as archive:
|
||||
archive.writestr("old.txt", "old")
|
||||
|
||||
result = run_cli(
|
||||
"--file-list",
|
||||
str(file_list),
|
||||
"--relative-root-marker",
|
||||
"repo",
|
||||
"--output",
|
||||
str(output),
|
||||
"--json",
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
summary = json.loads(result.stdout)
|
||||
self.assertEqual(summary["status"], "FAIL")
|
||||
self.assertIn("does not exist", summary["errors"][0])
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
self.assertEqual(archive.namelist(), ["old.txt"])
|
||||
|
||||
def test_file_outside_root_marker_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
outside = write_file(pathlib.Path(tmp) / "outside" / "file.md")
|
||||
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(outside)])
|
||||
|
||||
result = run_cli(
|
||||
"--file-list",
|
||||
str(file_list),
|
||||
"--relative-root-marker",
|
||||
"repo",
|
||||
"--output",
|
||||
str(pathlib.Path(tmp) / "bundle.zip"),
|
||||
"--json",
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
summary = json.loads(result.stdout)
|
||||
self.assertEqual(summary["status"], "FAIL")
|
||||
self.assertIn("root marker", summary["errors"][0])
|
||||
|
||||
def test_duplicate_relative_entries_fail(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
first = write_file(pathlib.Path(tmp) / "left" / "repo" / "same.md", "first")
|
||||
second = write_file(pathlib.Path(tmp) / "right" / "repo" / "same.md", "second")
|
||||
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(first), str(second)])
|
||||
|
||||
result = run_cli(
|
||||
"--file-list",
|
||||
str(file_list),
|
||||
"--relative-root-marker",
|
||||
"repo",
|
||||
"--output",
|
||||
str(pathlib.Path(tmp) / "bundle.zip"),
|
||||
"--json",
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
summary = json.loads(result.stdout)
|
||||
self.assertEqual(summary["status"], "FAIL")
|
||||
self.assertIn("Duplicate zip entry", "\n".join(summary["errors"]))
|
||||
|
||||
def test_output_path_matching_source_file_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp) / "repo"
|
||||
source = write_file(root / "bundle.zip")
|
||||
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(source)])
|
||||
|
||||
result = run_cli(
|
||||
"--file-list",
|
||||
str(file_list),
|
||||
"--relative-root-marker",
|
||||
"repo",
|
||||
"--output",
|
||||
str(source),
|
||||
"--json",
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
summary = json.loads(result.stdout)
|
||||
self.assertEqual(summary["status"], "FAIL")
|
||||
self.assertIn("same as a source file", "\n".join(summary["errors"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue