From 0cea8f6db057e092aae81ef62a3c458190c98a8c Mon Sep 17 00:00:00 2001 From: Pranav Nutalapati Date: Fri, 13 Feb 2026 18:21:12 -0800 Subject: [PATCH 1/3] feat: add submit script for hw1 --- hw1/scripts/submit.py | 198 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 hw1/scripts/submit.py diff --git a/hw1/scripts/submit.py b/hw1/scripts/submit.py new file mode 100644 index 00000000..ee3c484a --- /dev/null +++ b/hw1/scripts/submit.py @@ -0,0 +1,198 @@ +import contextlib +import glob +import os +import sys +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + +PATTERNS = [ + # globs to include in the submission zip + # if a directory is matched, all files under it are included + "src/**/*.py", + "exp/flow", + "exp/mse", + "pyproject.toml", + "uv.lock", + "README.md", +] + +ROOT = Path(__file__).parent.parent # path to the root directory + +# -------------------- +# formatting utilities +# -------------------- + + +@contextlib.contextmanager +def hide_cursor(): + """ + hide the terminal cursor while the context is active + (for things like progress bars) + """ + HIDE_CURSOR = "\033[?25l" + SHOW_CURSOR = "\033[?25h" + print(HIDE_CURSOR, end="", flush=True) + try: + yield + finally: + print(SHOW_CURSOR, end="", flush=True) + + +# ANSI escape codes for colors and styles +RESET = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" +COLORS = { + "INFO": "\033[36m", # cyan + "WARN": "\033[33m", # yellow + "ERR": "\033[31m", # red + "OK": "\033[32m", # green +} + + +def log(level, *msg, **kwargs): + """ + log a message with a colored level + """ + color = COLORS.get(level, "") + print(f"{color}[{level: ^6}]{RESET}", *msg, **kwargs) + + +def progress_bar(current, total, width=32): + """ + generate a progress bar string + """ + ratio = current / total + filled = int(width * ratio) + bar = "█" * filled + "░" * (width - filled) + percent = int(ratio * 100) + return f"{bar} {percent:3d}% ({current}/{total})" + + +def size_for_humans(num): + """ + convert a number of bytes to a human-readable string + """ + for unit in ["B", "KB", "MB", "GB"]: + if num < 1024: + return f"{num:.1f}{unit}" + num /= 1024 + return f"{num:.1f}TB" + + +# -------------- +# implementation +# -------------- + + +def collect_files(patterns): + """ + match globs against the file system and collect the paths of all matched + files. + + if a pattern matches a directory, include the directory recursively. + """ + files = set() + + log("INFO", "Finding files...") + missing_patterns = [] + + max_l = max(len(p) for p in patterns) + + def pattern_line(pattern, dots, overwrite=False, n=6): + if overwrite: + print("\r", end="") + if dots == -1: + d = "" + else: + idx = dots % n + d = "".join("█" if idx == i else "░" for i in range(n)) + log("INFO", f"{DIM}>{RESET} {pattern:<{max_l + 3}}{d}", flush=True, end="") + + for pattern in patterns: + with hide_cursor(): + dots = 0 + pattern_line(pattern, dots) + matched_files = set() + for m in glob.iglob(pattern, recursive=True): + dots += 1 + pattern_line(pattern, dots, overwrite=True) + p = Path(m) + if p.is_file(): + matched_files.add(p.resolve()) + elif p.is_dir(): + for f in p.rglob("*"): + dots += 1 + pattern_line(pattern, dots, overwrite=True) + if f.is_file(): + matched_files.add(f.resolve()) + pattern_line(pattern, -1, overwrite=True) + count = len(matched_files) + print(f"{DIM}{count} file{'s' if count != 1 else ''}{RESET}") + if not matched_files: + missing_patterns.append(pattern) + files.update(matched_files) + + if missing_patterns: + print() + log("WARN", "The following patterns did not match any files:") + for p in missing_patterns: + log("WARN", f"{DIM}>{RESET} {p}") + if input("Proceed with missing files? [y/N] ").lower() != "y": + log("ERR", "Aborted.") + sys.exit(1) + + log("OK", f"Collected {len(files)} files") + return files + + +def create_zip(zip_path, files): + """ + create a zip archive preserving the directory structure of the source files. + + :param zip_path: output zip file path + :param files: array of file paths to include + """ + total_size = sum(f.stat().st_size for f in files) + + log("INFO", f"Creating {BOLD}{zip_path.name}{RESET}") + + with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as zf, hide_cursor(): + for i, file in enumerate(sorted(files), 1): + rel = file.relative_to(ROOT) + zf.write(file, rel) + print("\r", end="") + log("INFO", progress_bar(i, len(files)), end="", flush=True) + print() + + log("OK", "Ready to submit!") + log("OK", f"{BOLD}Size:{RESET} {size_for_humans(total_size)}") + log("OK", f"{BOLD}Output:{RESET} {zip_path}") + + +def main(): + zip_path = ROOT / "submit.zip" + + if zip_path.exists(): + log("WARN", f"{BOLD}{zip_path.name}{RESET} already exists.") + if input("Overwrite? [y/N] ").lower() != "y": + log("ERR", "Aborted.") + sys.exit(1) + + log("INFO", f"Root directory: {ROOT}") + + files = collect_files(PATTERNS) + + if not files: + log("ERR", "No files collected. Aborting.") + sys.exit(1) + + create_zip(zip_path, files) + + +if __name__ == "__main__": + # weird trick to enable ansi colors on windows + if os.name == "nt": + os.system("") + + main() From 69576b7b75560205edddbfa4f8dff16b7f07a8c9 Mon Sep 17 00:00:00 2001 From: Pranav Nutalapati Date: Fri, 13 Feb 2026 20:02:13 -0800 Subject: [PATCH 2/3] feat: refactored to be easier to use --- hw1/.submit | 9 + hw1/scripts/submit.py | 198 --------------------- hw2/.submit | 8 + submit.py | 401 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 418 insertions(+), 198 deletions(-) create mode 100644 hw1/.submit delete mode 100644 hw1/scripts/submit.py create mode 100644 hw2/.submit create mode 100644 submit.py diff --git a/hw1/.submit b/hw1/.submit new file mode 100644 index 00000000..b5701174 --- /dev/null +++ b/hw1/.submit @@ -0,0 +1,9 @@ +# path globs to include in the submission zip +# if a directory is matched, all files under it are included + +src/**/*.py +exp/flow +exp/mse +pyproject.toml +uv.lock +README.md diff --git a/hw1/scripts/submit.py b/hw1/scripts/submit.py deleted file mode 100644 index ee3c484a..00000000 --- a/hw1/scripts/submit.py +++ /dev/null @@ -1,198 +0,0 @@ -import contextlib -import glob -import os -import sys -from pathlib import Path -from zipfile import ZIP_DEFLATED, ZipFile - -PATTERNS = [ - # globs to include in the submission zip - # if a directory is matched, all files under it are included - "src/**/*.py", - "exp/flow", - "exp/mse", - "pyproject.toml", - "uv.lock", - "README.md", -] - -ROOT = Path(__file__).parent.parent # path to the root directory - -# -------------------- -# formatting utilities -# -------------------- - - -@contextlib.contextmanager -def hide_cursor(): - """ - hide the terminal cursor while the context is active - (for things like progress bars) - """ - HIDE_CURSOR = "\033[?25l" - SHOW_CURSOR = "\033[?25h" - print(HIDE_CURSOR, end="", flush=True) - try: - yield - finally: - print(SHOW_CURSOR, end="", flush=True) - - -# ANSI escape codes for colors and styles -RESET = "\033[0m" -BOLD = "\033[1m" -DIM = "\033[2m" -COLORS = { - "INFO": "\033[36m", # cyan - "WARN": "\033[33m", # yellow - "ERR": "\033[31m", # red - "OK": "\033[32m", # green -} - - -def log(level, *msg, **kwargs): - """ - log a message with a colored level - """ - color = COLORS.get(level, "") - print(f"{color}[{level: ^6}]{RESET}", *msg, **kwargs) - - -def progress_bar(current, total, width=32): - """ - generate a progress bar string - """ - ratio = current / total - filled = int(width * ratio) - bar = "█" * filled + "░" * (width - filled) - percent = int(ratio * 100) - return f"{bar} {percent:3d}% ({current}/{total})" - - -def size_for_humans(num): - """ - convert a number of bytes to a human-readable string - """ - for unit in ["B", "KB", "MB", "GB"]: - if num < 1024: - return f"{num:.1f}{unit}" - num /= 1024 - return f"{num:.1f}TB" - - -# -------------- -# implementation -# -------------- - - -def collect_files(patterns): - """ - match globs against the file system and collect the paths of all matched - files. - - if a pattern matches a directory, include the directory recursively. - """ - files = set() - - log("INFO", "Finding files...") - missing_patterns = [] - - max_l = max(len(p) for p in patterns) - - def pattern_line(pattern, dots, overwrite=False, n=6): - if overwrite: - print("\r", end="") - if dots == -1: - d = "" - else: - idx = dots % n - d = "".join("█" if idx == i else "░" for i in range(n)) - log("INFO", f"{DIM}>{RESET} {pattern:<{max_l + 3}}{d}", flush=True, end="") - - for pattern in patterns: - with hide_cursor(): - dots = 0 - pattern_line(pattern, dots) - matched_files = set() - for m in glob.iglob(pattern, recursive=True): - dots += 1 - pattern_line(pattern, dots, overwrite=True) - p = Path(m) - if p.is_file(): - matched_files.add(p.resolve()) - elif p.is_dir(): - for f in p.rglob("*"): - dots += 1 - pattern_line(pattern, dots, overwrite=True) - if f.is_file(): - matched_files.add(f.resolve()) - pattern_line(pattern, -1, overwrite=True) - count = len(matched_files) - print(f"{DIM}{count} file{'s' if count != 1 else ''}{RESET}") - if not matched_files: - missing_patterns.append(pattern) - files.update(matched_files) - - if missing_patterns: - print() - log("WARN", "The following patterns did not match any files:") - for p in missing_patterns: - log("WARN", f"{DIM}>{RESET} {p}") - if input("Proceed with missing files? [y/N] ").lower() != "y": - log("ERR", "Aborted.") - sys.exit(1) - - log("OK", f"Collected {len(files)} files") - return files - - -def create_zip(zip_path, files): - """ - create a zip archive preserving the directory structure of the source files. - - :param zip_path: output zip file path - :param files: array of file paths to include - """ - total_size = sum(f.stat().st_size for f in files) - - log("INFO", f"Creating {BOLD}{zip_path.name}{RESET}") - - with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as zf, hide_cursor(): - for i, file in enumerate(sorted(files), 1): - rel = file.relative_to(ROOT) - zf.write(file, rel) - print("\r", end="") - log("INFO", progress_bar(i, len(files)), end="", flush=True) - print() - - log("OK", "Ready to submit!") - log("OK", f"{BOLD}Size:{RESET} {size_for_humans(total_size)}") - log("OK", f"{BOLD}Output:{RESET} {zip_path}") - - -def main(): - zip_path = ROOT / "submit.zip" - - if zip_path.exists(): - log("WARN", f"{BOLD}{zip_path.name}{RESET} already exists.") - if input("Overwrite? [y/N] ").lower() != "y": - log("ERR", "Aborted.") - sys.exit(1) - - log("INFO", f"Root directory: {ROOT}") - - files = collect_files(PATTERNS) - - if not files: - log("ERR", "No files collected. Aborting.") - sys.exit(1) - - create_zip(zip_path, files) - - -if __name__ == "__main__": - # weird trick to enable ansi colors on windows - if os.name == "nt": - os.system("") - - main() diff --git a/hw2/.submit b/hw2/.submit new file mode 100644 index 00000000..f6fcbbe0 --- /dev/null +++ b/hw2/.submit @@ -0,0 +1,8 @@ +# path globs to include in the submission zip +# if a directory is matched, all files under it are included + +src/**/*.py +exp +pyproject.toml +uv.lock +README.md diff --git a/submit.py b/submit.py new file mode 100644 index 00000000..0d7c70f6 --- /dev/null +++ b/submit.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2026 Pranav Nutalapati (@preyneyv) +# SPDX-License-Identifier: MIT + +""" +Utility script to create a submission zip file containing all relevant homework files. +No dependencies beyond the Python standard library, and works on all platforms. +Compatible with Python 3.6+. + +Usage: submit.py [--list] [--help] +""" + +import argparse +import contextlib +import glob +import logging +import os +import sys +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + +ROOT = Path(__file__).parent # path to the repo root +STREAM = sys.stderr +INTERACTIVE = STREAM.isatty() +MAX_ARCHIVE_SIZE = 100 * 1024 * 1024 # 100 MB + +# -------------------- +# logging + formatting +# -------------------- + +# ANSI escape codes for colors and styles +RESET = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" + +LEVEL_STYLES = { + logging.DEBUG: "\033[2m", # dim + logging.INFO: "\033[36m", # cyan + logging.WARNING: "\033[33m", # yellow + logging.ERROR: "\033[31m", # red + logging.CRITICAL: "\033[31m", # red +} + +if not INTERACTIVE: + # disable colors if not in an interactive terminal + RESET = "" + BOLD = "" + DIM = "" + for k in LEVEL_STYLES: + LEVEL_STYLES[k] = "" + +LEVEL_TAGS = { + logging.DEBUG: "DBG", + logging.INFO: "INFO", + logging.WARNING: "WARN", + logging.ERROR: "ERR", + logging.CRITICAL: "CRIT", +} + + +def _enable_windows_ansi(): + # weird trick to enable ANSI codes in Windows terminals. + if os.name == "nt": + os.system("") + + +def format_line(level: int, text: str) -> str: + """ + Format a log line with a colored level tag. + Sample: + [ INFO ] This is a test message + [ WARN ] This is a warning + [ ERR ] This is an error + """ + + tag = LEVEL_TAGS.get(level, "INFO") + color = LEVEL_STYLES.get(level, "") + + return f"{color}[{tag:^6}]{RESET} {text}" + + +class ColorFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + return format_line(record.levelno, record.getMessage()) + + +def setup_logger() -> logging.Logger: + """ + setup a logger using `ColorFormatter` that logs to stderr + (leaving stdout clean for pipeable output) + """ + logger = logging.getLogger("submit") + logger.setLevel(logging.INFO) + logger.propagate = False + + logger.handlers.clear() + + stream = sys.stderr + handler = logging.StreamHandler(stream) + handler.setLevel(logging.INFO) + handler.setFormatter(ColorFormatter()) + logger.addHandler(handler) + return logger + + +logger = setup_logger() + + +@contextlib.contextmanager +def hide_cursor(): + """hide the terminal cursor while performing an operation (useful for progress bars)""" + if not INTERACTIVE: + yield + return + + HIDE_CURSOR = "\033[?25l" + SHOW_CURSOR = "\033[?25h" + STREAM.write(HIDE_CURSOR) + STREAM.flush() + try: + yield + finally: + STREAM.write(SHOW_CURSOR) + STREAM.flush() + + +def progress_bar(current, total, width=32): + total = max(total, 1) + ratio = current / total + filled = int(width * ratio) + bar = "█" * filled + "░" * (width - filled) + percent = int(ratio * 100) + return f"{bar} {percent:3d}% ({current}/{total})" + + +def size_for_humans(num): + for unit in ["B", "KB", "MB"]: + if num < 1024: + return f"{num:.1f}{unit}" + num /= 1024 + return f"{num:.1f}GB" + + +def _input(prompt): + """ + prompt the user for input if in interactive mode, otherwise log a warning + and return an empty string + """ + if INTERACTIVE: + STREAM.write(prompt) + STREAM.flush() + return input() + else: + logger.warning(prompt + " [input skipped in non-interactive mode]") + return "" + + +def _inline_print(level, text: str): + if not INTERACTIVE: + return + STREAM.write("\r" + format_line(level, text)) + STREAM.flush() + + +def _inline_clear(): + if not INTERACTIVE: + return + # clear the line + STREAM.write("\r\033[K") + STREAM.flush() + + +# -------------- +# implementation +# -------------- + + +def find_submit_spec(current_dir: Path): + """ + walk up the directory tree from the current directory to find the nearest + .submit file. + """ + for d in [current_dir] + list(current_dir.parents): + submit_spec = d / ".submit" + if submit_spec.exists(): + return d + return None + + +def parse_submit_spec(hw_dir: Path): + """ + Parse the .submit file in the given homework directory to get the list of + file patterns to include in the submission. + """ + submit_spec = hw_dir / ".submit" + if not submit_spec.exists(): + logger.error(f"No .submit file found in {BOLD}{hw_dir}{RESET}.") + sys.exit(1) + + with submit_spec.open() as f: + patterns = [ + line.strip() for line in f if line.strip() and not line.startswith("#") + ] + return patterns + + +def collect_files(base: Path, patterns): + """ + Match globs against the file system and collect the paths of all matched files. + If a pattern matches a directory, include the directory recursively. + + :param base: the base directory to resolve patterns against + :param patterns: list of glob patterns to match (relative to base) + """ + files = set() + logger.info("Finding files...") + + max_l = max((len(p) for p in patterns), default=0) + + def pattern_line(pattern, dots, n=6): + if dots < 0: + d = "" + else: + idx = dots % n + d = "".join("█" if idx == i else "░" for i in range(n)) + return f"{DIM}>{RESET} {pattern:<{max_l + 3}}{d}" + + missing_patterns = [] + + for pattern in patterns: + matched_files = set() + dots = 0 + + with hide_cursor(): + _inline_print(logging.INFO, pattern_line(pattern, dots)) + + for m in glob.iglob(str(base / pattern), recursive=True): + dots += 1 + _inline_print(logging.INFO, pattern_line(pattern, dots)) + + p = Path(m) + if p.is_file(): + matched_files.add(p.resolve()) + elif p.is_dir(): + for f in p.rglob("*"): + if f.is_file(): + dots += 1 + _inline_print(logging.INFO, pattern_line(pattern, dots)) + matched_files.add(f.resolve()) + + _inline_clear() + count = len(matched_files) + logger.info( + f"{pattern_line(pattern, -1)}{DIM}{count} file{'s' if count != 1 else ''}{RESET}" + ) + + if not matched_files: + missing_patterns.append(pattern) + files.update(matched_files) + + logger.info(f"Collected {len(files)} files") + + if missing_patterns: + logger.warning("The following patterns did not match any files:") + for p in missing_patterns: + logger.warning(f"{DIM}>{RESET} {p}") + + return files, bool(missing_patterns) + + +def create_zip(zip_path: Path, base, files): + """ + Create a zip archive preserving the directory structure of the source files. + """ + logger.info(f"Creating {BOLD}{zip_path.name}{RESET}...") + + total = len(files) + + with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as zf, hide_cursor(): + for i, file in enumerate(sorted(files), 1): + rel = file.relative_to(base) + zf.write(file, rel) + _inline_print(logging.INFO, progress_bar(i, total)) + _inline_clear() + + logger.info(f"Created {BOLD}{zip_path.name}{RESET} successfully.") + total_size = zip_path.stat().st_size + if total_size > MAX_ARCHIVE_SIZE: + logger.warning( + f"Archive size {size_for_humans(total_size)} exceeds the limit of {size_for_humans(MAX_ARCHIVE_SIZE)}." + ) + logger.info(f"{BOLD}Size:{RESET} {size_for_humans(total_size)}") + logger.info(f"{BOLD}Output:{RESET} {zip_path}") + + +def main(): + _enable_windows_ansi() + + parser = argparse.ArgumentParser( + description="Create a submission zip file.", + usage="uv run submit.py [directory]", + ) + parser.add_argument( + "directory", nargs="?", help="The directory to archive (e.g. hw1)" + ) + parser.add_argument( + "output_path", + nargs="?", + help="(optional) output zip file path (default: /submit.zip)", + ) + parser.add_argument( + "--force", + "-f", + action="store_true", + help="Overwrite existing zip file without confirmation.", + ) + action_group = parser.add_mutually_exclusive_group() + action_group.add_argument( + "--list", + "-l", + action="store_true", + help="List the files that would be included without creating the zip.", + ) + action_group.add_argument( + "--check", + "-c", + action="store_true", + help="Check for missing files without creating the zip.", + ) + + args = parser.parse_args() + + if args.list: + action = "list" + elif args.check: + action = "check" + else: + action = "archive" + + if args.directory: + # use the explicitly provided directory + base = Path(args.directory).resolve() + else: + # find the nearest .submit file in the current directory or its parents + base = find_submit_spec(Path.cwd()) + if base is None: + logger.error("No `.submit` file found in any parent directories.") + parser.print_help() + sys.exit(1) + + if not base.exists(): + logger.error(f"Directory {BOLD}{base}{RESET} does not exist.") + sys.exit(1) + if not base.is_dir(): + logger.error(f"{BOLD}{base}{RESET} is not a directory.") + sys.exit(1) + + logger.info(f"Archiving {BOLD}{base}{RESET}") + zip_path = Path(args.output_path) if args.output_path else base / "submit.zip" + zip_path = zip_path.resolve() + + patterns = parse_submit_spec(base) + + if action == "archive": + if zip_path.exists(): + logger.warning(f"{BOLD}{zip_path.name}{RESET} already exists.") + if not args.force and _input("Overwrite? [y/N] ").lower() != "y": + logger.error("Aborted.") + sys.exit(1) + + files, has_missing = collect_files(base, patterns) + if not files: + logger.error("No files collected. Aborting.") + sys.exit(1) + + if action == "archive": + if ( + not args.force + and has_missing + and _input("Proceed with missing files? [Y/n] ").lower() == "n" + ): + logger.error("Aborted.") + sys.exit(1) + + create_zip(zip_path, base, files) + elif action == "list": + for f in sorted(files): + try: + print(f.relative_to(base)) + except ValueError: + print(f) + elif action == "check": + if has_missing: + logger.error("Some patterns did not match any files.") + sys.exit(1) + logger.info("All patterns matched at least one file.") + + +if __name__ == "__main__": + main() From c1166fc257d273057bf423c2b3c130a445bdbf22 Mon Sep 17 00:00:00 2001 From: Pranav Nutalapati Date: Fri, 13 Feb 2026 20:11:04 -0800 Subject: [PATCH 3/3] doc(submit): updated help text and added instructions to README.md --- hw1/README.md | 12 +++++++- hw2/README.md | 10 +++++++ submit.py | 80 +++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/hw1/README.md b/hw1/README.md index a12750be..0cdbb8e5 100644 --- a/hw1/README.md +++ b/hw1/README.md @@ -60,4 +60,14 @@ Then, you can download the logs and checkpoints to your local machine using a co ```bash uv run modal volume get hw1-imitation-volume exp/ -``` \ No newline at end of file +``` + +## Submission + +From within the `hw1/` directory, run the following command. + +```bash +uv run ../submit.py +``` + +This will generate a `submit.zip` archive that you can upload to Gradescope. diff --git a/hw2/README.md b/hw2/README.md index 1573ba1c..70897042 100644 --- a/hw2/README.md +++ b/hw2/README.md @@ -29,3 +29,13 @@ However, you may still use Modal if you wish. * If you see an error about `swig` when installing `box2d-py`, you may need to install `swig` and `cmake` on your machine. If you are using a Mac and have Homebrew installed, you can run `brew install swig cmake`. On Modal, it should already be installed. + +## Submission + +From within the `hw2/` directory, run the following command. + +```bash +uv run ../submit.py +``` + +This will generate a `submit.zip` archive that you can upload to Gradescope. diff --git a/submit.py b/submit.py index 0d7c70f6..70b9329f 100644 --- a/submit.py +++ b/submit.py @@ -4,11 +4,42 @@ # SPDX-License-Identifier: MIT """ -Utility script to create a submission zip file containing all relevant homework files. -No dependencies beyond the Python standard library, and works on all platforms. +Utility script to create a submission zip file containing all relevant homework +files. No dependencies beyond the Python standard library, and works on all +platforms. + Compatible with Python 3.6+. -Usage: submit.py [--list] [--help] +This script parses a `.submit` file in the target directory and selectively +includes matching files in the output zip archive. + +.submit syntax: +``` +# lines starting with # are comments and ignored +# paths are relative to the .submit file +# syntax is glob patterns (https://docs.python.org/3/library/glob.html) +# if a pattern matches a directory, the entire directory is included recursively + +src/**/*.py # all .py files in src and subdirectories +README.md +``` + +-------------------------------------------------------------------------------- + +usage: submit.py [directory] [output_path] [--list|--check] [--force] + +Create a submission zip file. By default, tries to archive the nearest directory +containing a `.submit` file. + +positional arguments: + directory The directory to archive (e.g. hw1) + output_path output zip file path (default: /submit.zip) + +options: + -h, --help show this help message and exit + --force, -f overwrite without confirmation + --list, -l list matching files + --check, -c check for missing files """ import argparse @@ -87,7 +118,7 @@ def format(self, record: logging.LogRecord) -> str: def setup_logger() -> logging.Logger: """ - setup a logger using `ColorFormatter` that logs to stderr + Setup a logger using `ColorFormatter` that logs to stderr. (leaving stdout clean for pipeable output) """ logger = logging.getLogger("submit") @@ -109,7 +140,10 @@ def setup_logger() -> logging.Logger: @contextlib.contextmanager def hide_cursor(): - """hide the terminal cursor while performing an operation (useful for progress bars)""" + """ + Hide the terminal cursor while performing an operation + (useful for progress bars). + """ if not INTERACTIVE: yield return @@ -144,8 +178,8 @@ def size_for_humans(num): def _input(prompt): """ - prompt the user for input if in interactive mode, otherwise log a warning - and return an empty string + Prompt the user for input if in interactive mode, otherwise log a warning + and return an empty string. """ if INTERACTIVE: STREAM.write(prompt) @@ -178,7 +212,7 @@ def _inline_clear(): def find_submit_spec(current_dir: Path): """ - walk up the directory tree from the current directory to find the nearest + Walk up the directory tree from the current directory to find the nearest .submit file. """ for d in [current_dir] + list(current_dir.parents): @@ -197,18 +231,23 @@ def parse_submit_spec(hw_dir: Path): if not submit_spec.exists(): logger.error(f"No .submit file found in {BOLD}{hw_dir}{RESET}.") sys.exit(1) + patterns = [] with submit_spec.open() as f: - patterns = [ - line.strip() for line in f if line.strip() and not line.startswith("#") - ] + for line in f: + line = line.strip() + first_hash = line.find("#") + if first_hash != -1: + line = line[:first_hash].strip() # remove comments + if line: + patterns.append(line) return patterns def collect_files(base: Path, patterns): """ - Match globs against the file system and collect the paths of all matched files. - If a pattern matches a directory, include the directory recursively. + Match globs against the file system and collect the paths of all matched + files. If a pattern matches a directory, include the directory recursively. :param base: the base directory to resolve patterns against :param patterns: list of glob patterns to match (relative to base) @@ -298,8 +337,11 @@ def main(): _enable_windows_ansi() parser = argparse.ArgumentParser( - description="Create a submission zip file.", - usage="uv run submit.py [directory]", + description=( + "Create a submission zip file. By default, tries to archive the " + "nearest directory containing a `.submit` file." + ), + usage="uv run submit.py [directory] [output_path] [--list|--check] [--force]", ) parser.add_argument( "directory", nargs="?", help="The directory to archive (e.g. hw1)" @@ -307,26 +349,26 @@ def main(): parser.add_argument( "output_path", nargs="?", - help="(optional) output zip file path (default: /submit.zip)", + help="output zip file path (default: /submit.zip)", ) parser.add_argument( "--force", "-f", action="store_true", - help="Overwrite existing zip file without confirmation.", + help="overwrite without confirmation", ) action_group = parser.add_mutually_exclusive_group() action_group.add_argument( "--list", "-l", action="store_true", - help="List the files that would be included without creating the zip.", + help="list matching files", ) action_group.add_argument( "--check", "-c", action="store_true", - help="Check for missing files without creating the zip.", + help="check for missing files", ) args = parser.parse_args()