Skip to content

Instantly share code, notes, and snippets.

@thegamecracks
Created January 15, 2026 14:07
Show Gist options
  • Select an option

  • Save thegamecracks/dff23360df8036217218582b0cdc361d to your computer and use it in GitHub Desktop.

Select an option

Save thegamecracks/dff23360df8036217218582b0cdc361d to your computer and use it in GitHub Desktop.
Create a .zip archive of a .NET solution directory with build artifacts omitted
"""Create a .zip archive of a .NET solution directory with build artifacts omitted.
If the solution directory is tracked in a Git repository that has a .gitignore to
exclude .NET build artifacts, consider `git archive <branch> -o <filename>` instead.
"""
import argparse
import fnmatch
import hashlib
import sys
from pathlib import Path, PurePosixPath
from typing import IO, Iterator
from zipfile import ZIP_DEFLATED, ZipFile
IGNORE_GLOBAL_PATTERNS = (".*", "*.doc", "*.docx", "*.docx#", "*.pdf")
IGNORE_PROJECT_PATTERNS = ("bin", "obj")
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
collision_group = parser.add_mutually_exclusive_group()
collision_group.add_argument(
"-c",
"--check",
action="store_true",
help="If dest exists, compare contents with current files",
)
collision_group.add_argument(
"-f",
"--force",
action="store_true",
help="Write archive even if dest exists",
)
parser.add_argument("solution", help="The solution directory to archive", type=Path)
parser.add_argument(
"dest",
help="Where to write the archive (defaults to name of solution directory)",
nargs="?",
type=Path,
)
args = parser.parse_args()
check: bool = args.check
force: bool = args.force
solution: Path = args.solution
dest: Path | None = args.dest
check_solution_path(solution)
if dest is None:
dest = solution.with_suffix(".zip")
dest_exists = dest.is_file()
if dest_exists and not check and not force:
sys.exit(
f"Destination {dest} already exists\n"
f"(use -f/--force to overwrite, or -c/--check to compare contents)"
)
elif dest_exists and check:
compare_solution_archive(solution, dest)
elif dest_exists and force:
# NOTE: consider renaming dest before writing
create_solution_archive(solution, dest)
print("Overwritten", dest)
elif check:
sys.exit(
f"Destination {dest} does not exist (omit -c/--check to write archive)"
)
else:
create_solution_archive(solution, dest)
print("Saved to", dest)
def check_solution_path(solution: Path) -> None:
if not solution.is_dir():
sys.exit(f"Solution must be a directory, not {dir}")
sln = (".sln", ".slnx")
sln = [solution / solution.with_suffix(s) for s in sln]
if not any(p.is_file() for p in sln):
sys.exit(f"Directory is missing solution file, {' or '.join(p.name for p in sln)}")
for child in solution.iterdir():
if child.is_dir() and any(child.glob("*.csproj")):
break
else:
sys.exit("No projects found inside solution directory (.csproj)")
def create_solution_archive(src: Path, dest: Path | IO[bytes]) -> None:
with ZipFile(dest, "w", compression=ZIP_DEFLATED) as dest_zip:
for path in filter_solution_files(src):
print(path)
dest_zip.write(path)
def filter_solution_files(solution: Path) -> Iterator[Path]:
for path in solution.iterdir():
if fnmatch_any(path.name, *IGNORE_GLOBAL_PATTERNS):
continue
elif path.is_dir():
yield from filter_project_files(path)
else:
yield path
def filter_project_files(project: Path) -> Iterator[Path]:
for path in project.iterdir():
if fnmatch_any(path.name, *IGNORE_PROJECT_PATTERNS, *IGNORE_GLOBAL_PATTERNS):
continue
else:
yield path
def fnmatch_any(name: str, *patterns: str) -> bool:
return any(fnmatch.fnmatch(name, p) for p in patterns)
def hexdigest(data: bytes | IO[bytes]) -> str:
m = hashlib.sha3_256()
if isinstance(data, (bytes, bytearray, memoryview)):
m.update(data)
else:
while chunk := data.read(2**20):
m.update(chunk)
return m.hexdigest()
def compare_solution_archive(src: Path, dest: Path) -> None:
lines: list[str] = []
with ZipFile(dest) as dest_zip:
deleted = {Path(info.filename) for info in dest_zip.filelist}
for path in filter_solution_files(src):
deleted.discard(path)
try:
with dest_zip.open(str(PurePosixPath(path))) as f:
old = hexdigest(f)
except KeyError:
lines.append(f"A {path}")
continue
with path.open("rb") as f:
new = hexdigest(f)
if old != new:
lines.append(f"M {path}")
for path in deleted:
lines.append(f"D {path}")
if not lines:
lines.append("No changes in content")
print("\n".join(lines))
if __name__ == "__main__":
main()
@thegamecracks
Copy link
Author

> py ziplab.py -h
usage: ziplab.py [-h] [-c | -f] solution [dest]

Create a .zip archive of a .NET solution directory with build artifacts omitted.

If the solution directory is tracked in a Git repository that has a .gitignore to
exclude .NET build artifacts, consider `git archive <branch> -o <filename>` instead.

positional arguments:
  solution     The solution directory to archive
  dest         Where to write the archive (defaults to name of solution directory)

options:
  -h, --help   show this help message and exit
  -c, --check  If dest exists, compare contents with current files
  -f, --force  Write archive even if dest exists

> py ziplab.py Lab1
Lab1\Lab1.slnx
Lab1\Medals.csv
Lab1\Question1\Program.cs
Lab1\Question1\Question1.csproj
Lab1\Question2\Program.cs
Lab1\Question2\Question2.csproj
Lab1\Question2\SinglyLinkedList.cs
Saved to Lab1.zip

> py ziplab.py Lab1
Destination Lab1.zip already exists
(use -f/--force to overwrite, or -c/--check to compare contents)

> py ziplab.py Lab1 --check
No changes in content

> rm Lab1\Medals.csv
> py ziplab.py Lab1 --check
D Lab1\Medals.csv

> py ziplab.py Lab1 --force
Lab1\Lab1.slnx
Lab1\Question1\Program.cs
Lab1\Question1\Question1.csproj
Lab1\Question2\Program.cs
Lab1\Question2\Question2.csproj
Lab1\Question2\SinglyLinkedList.cs
Overwritten Lab1.zip

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment