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]).
[][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" },
]