diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9526bb6..449b030 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,7 @@ name: Test, release on: + pull_request: + branches: [main] push: branches: [main] tags: ["v*.*.*"] @@ -13,9 +15,7 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true + - uses: astral-sh/setup-uv@v7 - run: uv python install ${{ matrix.python-version }} - name: Install mdcmd globally via uv tool run: uv tool install --python python${{ matrix.python-version }} . diff --git a/0.7.2.md b/0.7.2.md new file mode 100644 index 0000000..8ad24e8 --- /dev/null +++ b/0.7.2.md @@ -0,0 +1,17 @@ +v0.7.2: Error handling improvements + +๐Ÿ› Bug fixes +- Fix `mdcmd` to exit with non-zero code when embedded commands fail +- Commands that fail no longer crash with stack traces +- Failed commands are reported gracefully with exit code and output +- Other commands continue to execute in concurrent mode + +๐Ÿ“ฆ Dependencies +- Bump `utz` requirement to `>=0.21.1` (includes fix for `text()` handling `None`) + +๐Ÿงช Tests +- Add tests for command failure handling +- Add tests for mixed success/failure scenarios +- Add tests to ensure no uncaught exceptions on errors + +This release fixes a critical issue where `mdcmd` would exit with code 0 even when commands failed, breaking shell scripts using `set -e`. diff --git a/README.md b/README.md index 2dedb67..8736b59 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,7 @@ seq 3 # 3 ``` - -

- -โ˜๏ธ This block is updated programmatically by [`mdcmd`] (and verified [in CI](.github/workflows/ci.yml#L28-L31); see [raw README.md](README.md?plain=1#L5-L11)). -

+โ˜๏ธ This block is updated programmatically by [`mdcmd`] (and verified [in CI]; see [raw README.md][raw-mdcmd]). [![](https://img.shields.io/pypi/v/mdcmd?label=mdcmd&color=blue)][mdcmd] (formerly: [`bmdf`][bmdf]) @@ -35,11 +31,7 @@ seq 3 - [`toc`: Markdown Table of Contents](#toc) - [Examples](#examples) - -

- -โ˜๏ธ This TOC is generated programmatically by [`mdcmd`] and [`toc`] (and verified [in CI](.github/workflows/ci.yml#L28-L31); see [raw README.md](README.md?plain=1#L22-L36)). -

+โ˜๏ธ This TOC is generated programmatically by [`mdcmd`] and [`toc`] (and verified [in CI]; see [raw README.md][raw-toc]). ## Overview This package provides 3 CLIs: @@ -134,7 +126,7 @@ Notes: - If there's already output there, it will be replaced with new/current output. ### HTML example -Scripts that output raw HTML also work, e.g. [print-table.py](test/print-table.py) generates this table: +Scripts that output raw HTML also work, e.g. [print-table.py] generates this table: @@ -435,7 +427,7 @@ A `mktoc` script is also provided, which just wraps `mdcmd -x '^toc$'` (`mktoc` ## Examples - The examples in this file are all rendered by [`bmdf`] and [`mdcmd`]. -- [The TOC](#toc) is rendered by [`toc`]. +- [The TOC](#toc) above is rendered by [`toc`]. - The [`ci.yml`] GitHub Action verifies the examples and TOC. These repos' READMEs also use [`bmdf`] / [`mdcmd`] / [`toc`] to execute example commands (and in some cases also verify them with a GitHub Action): @@ -449,6 +441,7 @@ These repos' READMEs also use [`bmdf`] / [`mdcmd`] / [`toc`] to execute example - [ryan-williams/tdbs-dask] - [TileDB-Inc/scverse-ml-workshop-2024] + [`ci.yml`]: .github/workflows/ci.yml [`bmd`]: #bmd [`bmdf`]: #bmdf @@ -456,7 +449,17 @@ These repos' READMEs also use [`bmdf`] / [`mdcmd`] / [`toc`] to execute example [`bmdfff`]: #bmdfff [`mdcmd`]: #mdcmd [`toc`]: #toc + + [toc.py]: src/bmdf/toc.py + +[print-table.py]: test/print-table.py + +[in CI]: .github/workflows/ci.yml#L28-L31 + +[raw-mdcmd]: README.md?plain=1#L5-L11 + +[raw-toc]: README.md?plain=1#L18-L32 [runsascoded/utz]: https://github.com/runsascoded/utz?tab=readme-ov-file#utz [TileDB-Inc/scverse-ml-workshop-2024]: https://github.com/TileDB-Inc/scverse-ml-workshop-2024?tab=readme-ov-file#training-models-on-atlas-scale-single-cell-datasets diff --git a/pyproject.toml b/pyproject.toml index 5f37617..c90e2d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "mdcmd" description = "Execute commands in Markdown files, embed output, generate TOCs" -version = "0.7.1" +version = "0.7.2" readme = "README.md" authors = [ { name = "Ryan Williams", email = "ryan@runsascoded.com" } @@ -9,7 +9,7 @@ authors = [ license = { text = "MIT" } requires-python = ">=3.10" dependencies = [ - "utz>=0.19.1", + "utz>=0.21.1", "click>=8", ] diff --git a/scripts/gh-url.py b/scripts/gh-url.py new file mode 100755 index 0000000..cb94477 --- /dev/null +++ b/scripts/gh-url.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Generate GitHub URLs for links in README. + +Usage: + # For a file link: + python scripts/gh-url.py toc.py src/bmdf/toc.py + + # For a link with line numbers: + python scripts/gh-url.py 'in CI' .github/workflows/ci.yml#L28-L31 + +When README_ABSOLUTE_URLS=1 is set, generates absolute GitHub URLs instead of +relative paths. This is used during PyPI package builds to ensure links work +correctly on PyPI's README display. Requires being on a git tag when this +environment variable is set. +""" + +import sys +from pathlib import Path + +# Add parent directory to path to import gh_url_utils +sys.path.insert(0, str(Path(__file__).parent)) +from gh_url_utils import get_github_base_url, format_url + + +def main(): + """Generate the link definition.""" + if len(sys.argv) < 3: + print("Usage: gh-url.py ", file=sys.stderr) + sys.exit(1) + + link_text = sys.argv[1] + link_path = sys.argv[2] + + base_url = get_github_base_url() + url = format_url(link_path, base_url) + print(f"[{link_text}]: {url}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/gh_url_utils.py b/scripts/gh_url_utils.py new file mode 100644 index 0000000..baba571 --- /dev/null +++ b/scripts/gh_url_utils.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Shared utilities for generating GitHub URLs.""" + +import os +import subprocess +import sys + + +def get_github_base_url(): + """Get the appropriate GitHub base URL based on environment.""" + if os.getenv("README_ABSOLUTE_URLS"): + try: + result = subprocess.run( + ["git", "describe", "--exact-match", "--tags", "HEAD"], + capture_output=True, text=True, check=True + ) + ref = result.stdout.strip() + if not ref: + print("ERROR: README_ABSOLUTE_URLS=1 but not on a tag", file=sys.stderr) + sys.exit(1) + return f"https://github.com/runsascoded/mdcmd/blob/{ref}" + except subprocess.CalledProcessError: + print("ERROR: README_ABSOLUTE_URLS=1 but not on a tag", file=sys.stderr) + sys.exit(1) + return None # Use relative URLs + + +def format_url(path, base_url=None): + """Format a URL as either absolute or relative.""" + if base_url: + return f"{base_url}/{path}" + return path \ No newline at end of file diff --git a/scripts/raw-readme-link.py b/scripts/raw-readme-link.py new file mode 100755 index 0000000..3adc54d --- /dev/null +++ b/scripts/raw-readme-link.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Generate raw README.md link with dynamic line numbers. + +Usage: + python scripts/raw-readme-link.py mdcmd + python scripts/raw-readme-link.py toc + +Finds the relevant section in README.md and generates a link with line numbers. +When README_ABSOLUTE_URLS=1 is set, generates absolute GitHub URLs. +""" + +import sys +from pathlib import Path + +# Add current directory to path to import gh_url_utils +sys.path.insert(0, str(Path(__file__).parent)) +from gh_url_utils import get_github_base_url, format_url + + +def find_readme_lines(section): + """Find line numbers for the relevant section in README.md.""" + try: + content = Path("README.md").read_text() + lines = content.splitlines() + + # Map section names to their command patterns + patterns = { + "mdcmd": "", + "toc": "" + } + + if section not in patterns: + return None + + pattern = patterns[section] + + # Find the pattern and determine the end based on content type + for i, line in enumerate(lines, 1): + if pattern in line: + # For mdcmd example, find until closing ``` + if section == "mdcmd": + for j in range(i+1, len(lines)+1): + if j > len(lines) or lines[j-1] == "```": + return (i, j) + # For toc, find until empty line + else: + for j in range(i+1, len(lines)+1): + if j > len(lines): + return (i, j-1) + if not lines[j-1].strip(): + return (i, j-1) + except: + pass + return None + + +def main(): + """Generate the raw README link with line numbers.""" + if len(sys.argv) < 2: + print("Usage: raw-readme-link.py {mdcmd|toc}", file=sys.stderr) + sys.exit(1) + + section = sys.argv[1] + if section not in ["mdcmd", "toc"]: + print(f"Unknown section: {section}", file=sys.stderr) + sys.exit(1) + + lines = find_readme_lines(section) + base_url = get_github_base_url() + + # Build the path with line numbers + if lines: + start, end = lines + readme_path = f"README.md?plain=1#L{start}-L{end}" + else: + readme_path = "README.md?plain=1" + + url = format_url(readme_path, base_url) + + # Generate the appropriate link text based on section + print(f"[raw-{section}]: {url}") + + +if __name__ == "__main__": + main() diff --git a/src/mdcmd/cli/__init__.py b/src/mdcmd/cli/__init__.py index 7d64035..476dda0 100644 --- a/src/mdcmd/cli/__init__.py +++ b/src/mdcmd/cli/__init__.py @@ -20,15 +20,28 @@ CMD_LINE_RGX = re.compile(r'') HTML_OPEN_RGX = re.compile(r'<(?P\w+)(?: +\w+(?:="[^"]*")?)* *>.*') +LINK_DEF_RGX = re.compile(r'^\[(?P[^\]]+)\]: (?P.+)$') Write = Callable[[str], None] DEFAULT_FILE_ENV_VAR = 'MDCMD_DEFAULT_PATH' DEFAULT_FILE = 'README.md' -async def async_text(cmd: str | list[str], env: dict | None = None) -> str: - text = await proc.aio.text(cmd, env=env) - return text.rstrip('\n') +class CommandError: + """Marker class to indicate a command failed.""" + def __init__(self, cmd: str | list[str], returncode: int, output: bytes | None = None): + self.cmd = cmd + self.returncode = returncode + self.output = output + + +async def async_text(cmd: str | list[str], env: dict | None = None) -> str | CommandError: + from subprocess import CalledProcessError + try: + text = await proc.aio.text(cmd, env=env) + return text.rstrip('\n') + except CalledProcessError as e: + return CommandError(cmd, e.returncode, e.output) async def async_line(arg: str) -> str: @@ -41,9 +54,9 @@ async def process_path( patterns: Patterns, write_fn: Write, concurrent: bool = True, -): - blocks: list[Coroutine[None, None, str]] = [] - def write(arg: str | Coroutine[Any, Any, str]): +) -> list[CommandError]: + blocks: list[Coroutine[None, None, str | CommandError]] = [] + def write(arg: str | Coroutine[Any, Any, str | CommandError]): blocks.append(async_line(arg) if isinstance(arg, str) else arg) with open(path, 'r') as fd: @@ -67,6 +80,7 @@ def write(arg: str | Coroutine[Any, Any, str]): cmd_env = os.environ.copy() cmd_env['MDCMD_FILE'] = path + is_link_def = False try: line = next(lines) if html_match := HTML_OPEN_RGX.fullmatch(line): @@ -87,6 +101,10 @@ def write(arg: str | Coroutine[Any, Any, str]): except StopIteration: break close_lines = None + elif LINK_DEF_RGX.match(line): + # Link definition block - skip until empty line or non-link-def line + is_link_def = True + close_lines = None elif not line: close_lines = None else: @@ -101,17 +119,44 @@ def write(arg: str | Coroutine[Any, Any, str]): line = next(lines) write(async_text(cmd, env=cmd_env)) - if close_lines is None: + # Don't add blank line after link definitions + if close_lines is None and not is_link_def: write("") + errors: list[CommandError] = [] if concurrent: gathered = await gather(*blocks) for line in gathered: - write_fn(line) + if isinstance(line, CommandError): + errors.append(line) + err(f"Command failed with exit code {line.returncode}: {line.cmd if isinstance(line.cmd, str) else ' '.join(line.cmd)}") + if line.output: + output_str = line.output.decode() if isinstance(line.output, bytes) else line.output + # Only show first few lines of output to avoid clutter + output_lines = output_str.rstrip('\n').split('\n') + if len(output_lines) > 5: + err(f"Output (first 5 lines):\n" + '\n'.join(output_lines[:5])) + else: + err(f"Output:\n{output_str.rstrip()}") + else: + write_fn(line) else: for block in blocks: line = await block - write_fn(line) + if isinstance(line, CommandError): + errors.append(line) + err(f"Command failed with exit code {line.returncode}: {line.cmd if isinstance(line.cmd, str) else ' '.join(line.cmd)}") + if line.output: + output_str = line.output.decode() if isinstance(line.output, bytes) else line.output + output_lines = output_str.rstrip('\n').split('\n') + if len(output_lines) > 5: + err(f"Output (first 5 lines):\n" + '\n'.join(output_lines[:5])) + else: + err(f"Output:\n{output_str.rstrip()}") + else: + write_fn(line) + + return errors @contextmanager @@ -174,7 +219,7 @@ def main( tmpdir = None if no_cwd_tmpdir else getcwd() with out_fd(inplace, path, out_path, dir=tmpdir) as write: - asyncio.run( + errors = asyncio.run( process_path( path=path, dry_run=dry_run, @@ -186,6 +231,11 @@ def main( amend_run(amend) + if errors: + import sys + err(f"\n{len(errors)} command(s) failed") + sys.exit(1) + if __name__ == '__main__': main() diff --git a/test/print-ci-yml-ref.py b/test/print-ci-yml-ref.py deleted file mode 100644 index 533630c..0000000 --- a/test/print-ci-yml-ref.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the 'updated programmatically' notes with correct CI links. - -When README_ABSOLUTE_URLS=1 is set, generates absolute GitHub URLs instead of -relative paths. This is used during PyPI package builds to ensure links work -correctly on PyPI's README display. Requires being on a git tag when this -environment variable is set. - -Usage: - # Normal usage (relative URLs for GitHub): - python test/print-ci-yml-ref.py mdcmd - - # PyPI build (absolute URLs, must be on a tag): - README_ABSOLUTE_URLS=1 python test/print-ci-yml-ref.py mdcmd -""" - -import os -import sys -from pathlib import Path - - -def find_ci_step_lines(ci_path=".github/workflows/ci.yml"): - """Find line numbers for the mdcmd CI step.""" - content = Path(ci_path).read_text() - lines = content.splitlines() - - step_name = "Verify README examples and TOC are up to date" - for i, line in enumerate(lines, 1): - if f"name: {step_name}" in line: - start = i - # Find the end of this step - for j in range(i+1, len(lines)+1): - if j > len(lines): - return (start, j-1) - line_j = lines[j-1] - # Stop at next step or job - if (line_j.strip().startswith('- name:') or - (line_j.strip() and not line_j.startswith(' '))): - return (start, j-1) - # Update end if we find content - if line_j.strip(): - end = j - return (start, end) - return None - - -def find_readme_lines(tool): - """Find line numbers for the relevant section in README.md.""" - try: - content = Path("README.md").read_text() - lines = content.splitlines() - - if tool == "mdcmd": - # Find "" through first subsequent "```" - for i, line in enumerate(lines, 1): - if "" in line: - for j in range(i+1, len(lines)+1): - if j > len(lines) or lines[j-1] == "```": - return (i, j) - elif tool == "toc": - # Find "" until first subsequent empty line - for i, line in enumerate(lines, 1): - if "" in line: - for j in range(i+1, len(lines)+1): - if j > len(lines): - return (i, j-1) - if not lines[j-1].strip(): - return (i, j-1) - except: - pass - return None - - -def main(): - """Generate the update notes.""" - if len(sys.argv) < 2: - print("Usage: print-ci-yml-ref.py {mdcmd|toc}", file=sys.stderr) - sys.exit(1) - - tool = sys.argv[1] - if tool not in ["mdcmd", "toc"]: - print(f"Unknown tool: {tool}", file=sys.stderr) - sys.exit(1) - - ci_lines = find_ci_step_lines() - readme_lines = find_readme_lines(tool) - - if not ci_lines: - print("CI step not found", file=sys.stderr) - sys.exit(1) - - # Check env var for absolute URLs (for PyPI builds) - # When publishing to PyPI, we need absolute URLs since PyPI doesn't resolve - # relative GitHub paths. This requires being on a tag to ensure stable URLs. - if os.getenv("README_ABSOLUTE_URLS"): - import subprocess - try: - result = subprocess.run( - ["git", "describe", "--exact-match", "--tags", "HEAD"], - capture_output=True, text=True, check=True - ) - ref = result.stdout.strip() - if not ref: - print("ERROR: README_ABSOLUTE_URLS=1 but not on a tag", file=sys.stderr) - sys.exit(1) - except subprocess.CalledProcessError: - print("ERROR: README_ABSOLUTE_URLS=1 but not on a tag", file=sys.stderr) - sys.exit(1) - - # Generate absolute GitHub URLs using the tag as ref (for PyPI) - base_url = f"https://github.com/runsascoded/mdcmd/blob/{ref}" - ci_path = f"{base_url}/.github/workflows/ci.yml" - readme_base = f"{base_url}/README.md?plain=1" - else: - # Use relative URLs for normal GitHub viewing - ci_path = ".github/workflows/ci.yml" - readme_base = "README.md?plain=1" - - # Add line numbers if we found them - if readme_lines: - start, end = readme_lines - readme_link = f"[raw README.md]({readme_base}#L{start}-L{end})" - else: - readme_link = f"[raw README.md]({readme_base})" - - ci_start, ci_end = ci_lines - print("

\n") - if tool == "mdcmd": - print(f'โ˜๏ธ This block is updated programmatically by [`mdcmd`] (and verified [in CI]({ci_path}#L{ci_start}-L{ci_end}); see {readme_link}).') - else: # tool == "toc" - print(f'โ˜๏ธ This TOC is generated programmatically by [`mdcmd`] and [`toc`] (and verified [in CI]({ci_path}#L{ci_start}-L{ci_end}); see {readme_link}).') - print("

") - - -if __name__ == "__main__": - main() diff --git a/test/test_mdcmd.py b/test/test_mdcmd.py index 0e09d93..b43efdd 100644 --- a/test/test_mdcmd.py +++ b/test/test_mdcmd.py @@ -21,3 +21,72 @@ def test_mdcmd(): open(out_path, 'r', encoding='utf-8') as out_fd, ): assert in_fd.read() == out_fd.read() + + +def test_mdcmd_command_failure(): + """Test that mdcmd exits with non-zero code when commands fail.""" + with cd(ROOT): + runner = CliRunner() + with TemporaryDirectory() as tmpdir: + # Create a markdown file with a failing command + in_path = join(tmpdir, 'test.md') + out_path = join(tmpdir, 'output.md') + with open(in_path, 'w') as f: + f.write('# Test\n\n\n```\nold\n```\n') + + res = runner.invoke(main, [in_path, out_path], catch_exceptions=False) + assert res.exit_code == 1 + + +def test_mdcmd_mixed_success_failure(): + """Test that mdcmd continues processing after failures and reports all errors.""" + with cd(ROOT): + runner = CliRunner() + with TemporaryDirectory() as tmpdir: + in_path = join(tmpdir, 'test.md') + out_path = join(tmpdir, 'output.md') + with open(in_path, 'w') as f: + f.write('''# Test + + +``` +old +``` + + +``` +old +``` + + +``` +old +``` +''') + + res = runner.invoke(main, [in_path, out_path], catch_exceptions=False) + assert res.exit_code == 1 + + # Verify successful commands still wrote output + with open(out_path) as f: + file_output = f.read() + assert 'first' in file_output + assert 'third' in file_output + + +def test_mdcmd_no_crash_on_error(): + """Test that mdcmd doesn't crash with traceback on command failure.""" + with cd(ROOT): + runner = CliRunner() + with TemporaryDirectory() as tmpdir: + in_path = join(tmpdir, 'test.md') + out_path = join(tmpdir, 'output.md') + with open(in_path, 'w') as f: + f.write('# Test\n\n\n```\nold\n```\n') + + # Use catch_exceptions=True (default) to catch exceptions + # If there's an uncaught exception, it will be in res.exception + res = runner.invoke(main, [in_path, out_path]) + assert res.exit_code == 1 + # Should not have an uncaught exception + assert res.exception is None or not isinstance(res.exception, Exception) diff --git a/uv.lock b/uv.lock index b73b12c..e68e1d1 100644 --- a/uv.lock +++ b/uv.lock @@ -62,7 +62,7 @@ test = [ requires-dist = [ { name = "click", specifier = ">=8" }, { name = "pytest", marker = "extra == 'test'" }, - { name = "utz", specifier = ">=0.19.1" }, + { name = "utz", specifier = ">=0.21.1" }, ] provides-extras = ["test"] @@ -113,11 +113,11 @@ wheels = [ [[package]] name = "stdlb" -version = "0.0.4" +version = "0.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/45/ae96cab0805c1992812d40e86f555926cf426f61862c34777c3f54a41de9/stdlb-0.0.4.tar.gz", hash = "sha256:5f397ef335d15a7b2fe18693f15b738310a5c6c8b6cfdc80cc54ed9926dddc62", size = 3102, upload-time = "2023-08-05T15:35:41.648Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/e2/6efe231933a33bd9d1af2b6d0f3649f199ae797c3865159c55c509995da8/stdlb-0.1.1.tar.gz", hash = "sha256:45dbebf9eefa5c4363d5e40e980ad86b25849561f0e33a788d3f7640413a677c", size = 5331, upload-time = "2025-11-01T16:16:11.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/d7/8147edcba4f7d56c525f1a882c84e74586c35a6339a2354519284244492a/stdlb-0.0.4-py3-none-any.whl", hash = "sha256:5d268f17b7960270960f8f85be4fa3b003195313525c31f32192854ab63eafe2", size = 3495, upload-time = "2023-08-05T15:35:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d9/b8e183026e24b2911d674c8f12af06564e57d5e9e53a332140ad96517b48/stdlb-0.1.1-py3-none-any.whl", hash = "sha256:122b21f17ec10b20e4804898bf7e590607506182696822e698db2c6ea9b92125", size = 5950, upload-time = "2025-11-01T16:16:09.935Z" }, ] [[package]] @@ -170,12 +170,12 @@ wheels = [ [[package]] name = "utz" -version = "0.20.0" +version = "0.21.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "stdlb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/4c/0a6a20fd6ffda6f3cfaeeae9737760fb81a45c2ba3ec888355e1a1bf58d0/utz-0.20.0.tar.gz", hash = "sha256:9c0af213188ab8937043efefa952e12ff452af5ddd0d9705501073664f2081e7", size = 97240, upload-time = "2025-04-23T00:08:29.679Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/22/1ff4ec36521927c0fff362a61841f2bd9df220c47af0e61b5caa784433b3/utz-0.21.1.tar.gz", hash = "sha256:ee9dd49c124494d68fceba09fb0258ab393c48b5390dfd2cbd69fe6e81a7dc4d", size = 98162, upload-time = "2025-11-01T19:59:04.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/0d/5cb8e154511d446f46cd347fec3653b66ccb5477711bc848ad9ac3d30099/utz-0.20.0-py3-none-any.whl", hash = "sha256:8fc16a3c32f4a0345d2eeea7b4064e646f0bd7f56dc36717fdf6b02de6ca2df1", size = 82629, upload-time = "2025-04-23T00:08:27.97Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/18498348f27e04aff83158bcc8b260ddec0b21d3287fa316afb3cd51c76e/utz-0.21.1-py3-none-any.whl", hash = "sha256:6539d51325d92449761194df38da8bbae6f645d8078ef6bb5b0072c6d4d70d2e", size = 82901, upload-time = "2025-11-01T19:59:03.016Z" }, ]