✨feat(cli): add cli features

add cli using typer, its option --input-dir, --input-files and --output-dir, add related tests, add logging, add and update of the package version in `logwatcher/__init__.py`, correct templates in `reporter.py`
This commit is contained in:
2026-09-01 14:53:18 +02:00
parent ee118b531d
commit b6dab13bd1
6 changed files with 613 additions and 11 deletions
+4 -2
View File
@@ -1,10 +1,12 @@
[project]
name = "logwatcher"
version = "0.1.0"
version = "0.0.1"
description = "Trier, classifier et extraire automatiquement les entrées pertinentes d'un fichier de logs applicatif."
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
dependencies = [
"typer>=0.27.2",
]
[dependency-groups]
dev = [
+3
View File
@@ -1,4 +1,7 @@
from importlib.metadata import version
from logwatcher.config import *
from logwatcher.logging_config import setup_logging
__version__ = version("logwatcher")
logger = setup_logging()
+106 -3
View File
@@ -1,9 +1,112 @@
from logwatcher import logger
from pathlib import Path
from typing import Annotated
import typer
from logwatcher import __version__, logger
from logwatcher.classifier import classify_log_entries
from logwatcher.models import LogEntry
from logwatcher.parser import parse_log_file
from logwatcher.reporter import write_log_report
app = typer.Typer()
def app():
def version_callback(value: bool):
"""
Print the version of package.
Stop the CLI after printing version.
:return:
"""
if value:
print(f"logwatcher version: {__version__}")
raise typer.Exit()
def _is_log_file(path: Path) -> bool:
"""
Return True if file is a log file (.txt, .log, or extensionless).
:param path: Path to log file
"""
if not path.is_file():
return False
return path.suffix in {"", ".txt", ".log"}
def _get_files_from_dir(input_dir: Path) -> list[Path]:
if not input_dir.exists():
raise FileNotFoundError(f"No such directory at '{input_dir}'")
return [file for file in input_dir.iterdir() if _is_log_file(file)]
def _files_to_log_entries(input_files: list[Path]) -> list[LogEntry]:
log_entries = []
for file in input_files:
if not file.is_file():
raise ValueError(f"'{file}' is not a file")
log_entries.extend(parse_log_file(file))
return log_entries
@app.command()
def cli(
output_dir: Annotated[
Path,
typer.Option(
"--output-dir", help="Output directories where reports will be stored."
),
] = Path("output"),
input_files: Annotated[
list[Path] | None, typer.Option("--input-files", help="Log files to scan.")
] = None,
input_dir: Annotated[
Path | None,
typer.Option("--input-dir", help="Directories containing log files."),
] = None,
version: Annotated[
bool,
typer.Option(
"--version",
help="Get the version of the package",
callback=version_callback,
is_eager=True,
),
] = False,
) -> None:
"""
Entry point for logwatcher.
Convert log files into two reports, one of n2 related logs, one without any n2 related logs.
These reports are sent by mail to n2 support technicians.
These log entries are used to generate incidents on Ivanti using its API.
Args:
input_files: Log files to scan. Either input_files or input_dir must be provided.
input_dir: Directories containing log files. Either input_files or input_dir must be provided.
output_dir: Required. Output directories where reports will be stored.
version: Option that returns the version of package
Returns:
None
"""
logger.info("logwatcher starts its work.")
print("hello, world!")
if input_files and input_dir:
raise typer.BadParameter(
"Only one of input_files or input_dir must be provided."
)
if input_files:
logger.info(f"input_files option selected.\n input_files: '{input_files}'")
log_files = input_files
elif input_dir:
logger.info(f"input_dir option selected with value '{input_dir}'")
log_files = _get_files_from_dir(input_dir)
else:
raise typer.BadParameter("Either input_files or input_dir must be provided")
log_entries = _files_to_log_entries(log_files)
relevant, irrelevant = classify_log_entries(log_entries)
write_log_report(relevant, irrelevant, len(log_files), output_dir)
logger.info("logwatcher has finished its task.")
+5 -5
View File
@@ -10,8 +10,8 @@ BASE_TEMPLATE = Template("""
RAPPORT D'ANALYSE DE LOGS
=========================
Période\t : $start_date -> $end_date
Fichiers lu\t : $nb_files
Nombre total d'erreurs\t: $nb_errors
Fichier(s) lu(s)\t : $nb_files
Nombre total d'erreur(s)\t: $nb_errors
$sub_content
""")
@@ -19,7 +19,7 @@ N2_SUPPORT_TEMPLATE = Template("""
=========================
ERREURS SUPPORT N2
=========================
Nombre d'erreurs\t : $nb_errors
Nombre d'erreur(s)\t : $nb_errors
$error_list
""")
@@ -27,7 +27,7 @@ OTHER_TEMPLATE = Template("""
=========================
AUTRES ERREURS
=========================
Nombre d'erreurs\t : $nb_errors
Nombre d'erreur(s)\t : $nb_errors
$error_list
""")
@@ -35,7 +35,7 @@ ERROR_TEMPLATE = Template("""
[$index] $error_name
\tMagasin: $store_name
\tMDC: $mdc
\tServeur: $server_ip
\tIP du MDC: $server_ip
\tHeure: $error_time
\tRaison: $error_message
""")
+423
View File
@@ -0,0 +1,423 @@
import re
from pathlib import Path
import pytest
from typer.testing import CliRunner
from logwatcher import __version__
from logwatcher.cli import _get_files_from_dir, _is_log_file, app
runner = CliRunner()
@pytest.fixture(name="output_path")
def get_output_path_fixture(tmp_path: Path):
"""
Fixture that return output directory path where all logs and output files
will be located during tests.
Args:
tmp_path: Temporary root path for testing results
Returns:
None
"""
return tmp_path / "output"
def get_argument_from_files(log_dir: Path, output_path: Path) -> list[str]:
"""
Utility function that implement an argument list to call cli
Args:
log_dir: Path to the logwatcher directory
output_path: Temporary path for all output generated during testing
Returns:
List of arguments in string format
"""
files = [
str(file) for file in log_dir.iterdir() if file.suffix in ("", ".txt", ".log")
]
arguments = ["--output-dir", str(output_path)]
for file in files:
arguments += ["--input-files", file]
return arguments
def test_cli_help():
"""
Test the help command and assert that all options are listed
Returns:
None
"""
results = runner.invoke(app, ["--help"])
assert results.exit_code == 0
assert "Usage: cli [OPTIONS]" in results.output
assert "--input-dir" in results.output
assert "--input-files" in results.output
assert "--output-dir" in results.output
assert "--version" in results.output
def test_version_cli():
"""
Test the version option. The version must be printed
Returns:
None
"""
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert f"logwatcher version: {__version__}" in result.output
def test_is_log_file(tmp_path: Path):
"""
Assert only .log, .txt or file without extensions are valid
Args:
tmp_path: Temporary root path for testing results
Returns:
None
"""
# set up files
Path(tmp_path / "a.log").touch()
Path(tmp_path / "a.txt").touch()
Path(tmp_path / "a.csv").touch()
Path(tmp_path / "a.py").touch()
Path(tmp_path / "a").touch()
Path(tmp_path / "a.pdf").touch()
# test
assert _is_log_file(Path(tmp_path / "a.log"))
assert _is_log_file(Path(tmp_path / "a.txt"))
assert _is_log_file(Path(tmp_path / "a")) # without extensions
assert not _is_log_file(Path(tmp_path / "a.csv"))
assert not _is_log_file(Path(tmp_path / "a.py"))
assert not _is_log_file(Path(tmp_path / "a.pdf"))
def test_get_files_from_dir_empty_dir(tmp_path: Path):
"""
Assert that a non-existing directory (or wrong path) cannot be
given as a working input directory.
Assert that everything works even if the directory is empty.
Args:
tmp_path: Temporary root path for testing results
Returns:
None
"""
with pytest.raises(FileNotFoundError):
_get_files_from_dir(tmp_path / "input_test")
input_test_dir = tmp_path / "input_test"
input_test_dir.mkdir()
assert input_test_dir.is_dir()
assert _get_files_from_dir(input_test_dir) == []
def test_get_files_from_dir_not_empty_dir(tmp_path: Path):
"""
Assert the right files are retrieved if the directory is not empty.
Args:
tmp_path: Temporary root path for testing results
Returns:
None
"""
# create directory
input_test_dir = tmp_path / "input_test"
input_test_dir.mkdir()
# populate it with files
Path(input_test_dir / "a.log").touch()
Path(input_test_dir / "a.txt").touch()
Path(input_test_dir / "a.csv").touch()
Path(input_test_dir / "a.py").touch()
Path(input_test_dir / "a").touch()
Path(input_test_dir / "a.pdf").touch()
assert input_test_dir.is_dir()
file_list = _get_files_from_dir(input_test_dir)
assert file_list != []
assert len(file_list) == 3
assert {file.name for file in file_list} == {"a.log", "a.txt", "a"}
def test_cli_input_files_and_input_dir(output_path: Path):
"""
Assert that input files and input dir options cannot be provided in the same command.
Args:
tmp_path: Temporary root path for testing results
Returns:
None
"""
results = runner.invoke(
app,
[
"--input-files",
"a.log",
"--input-dir",
"logs",
"--output-dir",
str(output_path),
],
)
assert results.exit_code == 2
assert "Only one of input_files or input_dir must be provided." in results.output
assert not output_path.exists()
def test_cli_neither_input_files_nor_input_dir():
"""
Test calling cli without providing input files or input dir options.
Must return an error on provided parameters (BadParameter = exit code 2)
Returns:
None
"""
results = runner.invoke(app)
assert results.exit_code == 2
assert "Either input_files or input_dir must be provided" in results.output
def test_cli_bad_input_file(tmp_path: Path, output_path: Path, invalid_log_dir: Path):
"""
Test calling cli with bad input file (directory instead of file)
Must return an exit code at 1 and the ValueError exception raised
while `_files_to_log_entries()` was running.
Args:
tmp_path: Temporary root path for testing results
output_path: Temporary path for all output generated during testing
invalid_log_dir: Fixture directory of invalid logs
Returns:
None
"""
directory = str(tmp_path)
results = runner.invoke(
app,
["--input-files", directory, "--output-dir", str(output_path)],
)
assert results.exit_code == 1
assert isinstance(results.exception, ValueError)
assert f"'{directory}' is not a file" in results.exception.args[0]
assert (
not output_path.exists()
) # would be created if `directory` was a valid parameter
def test_cli_empty_input_files(output_path: Path, invalid_log_dir: Path):
"""
Test calling cli with empty input files.
Must pass and generate reports that mention reading a file only.
All other parameters must be at 0.
Args:
output_path: Temporary path for all output generated during testing
invalid_log_dir: Fixture directory of invalid logs
Returns:
None
"""
empty_file = str(next(invalid_log_dir.glob("empty_file.txt")))
results = runner.invoke(
app,
["--input-files", empty_file, "--output-dir", str(output_path)],
)
assert results.exit_code == 0
# check files
nb_files = 1 # only empty_file.txt should have been read
file_list = list(output_path.iterdir())
assert len(file_list) == 3
for file in file_list:
content = file.read_text(encoding="windows-1252")
assert file.name in ("all.log", "n2.log", "other.log")
assert f"Fichier(s) lu(s)\t : {nb_files}" in content
assert "Nombre total d'erreur(s)\t: 0" in content
def test_cli_input_files_or_input_dir(
tmp_path: Path, output_path: Path, original_log_dir: Path
):
"""
Assert that calling the CLI with either input files or input dir options
is a valid process.
Args:
tmp_path: Temporary root path for testing results
output_path: Temporary path for all output generated during testing
original_log_dir: Fixture directory of initial logs received by n2 technicians
Returns:
None
"""
# output for input-files option
input_files_output = output_path / "input_files"
# save number of files from original log directory for future assertion test
nb_files = len(list(original_log_dir.iterdir()))
# test with `input files` option provided only
result = runner.invoke(
app, get_argument_from_files(original_log_dir, input_files_output)
)
assert result.exit_code == 0
assert result.exception is None
output_file_list = list(input_files_output.iterdir())
assert len(output_file_list) == 3
# check files
for file in output_file_list:
assert file.name in ("all.log", "n2.log", "other.log")
assert file.stat().st_size != 0 # must contain basic execution information
assert f"Fichier(s) lu(s)\t : {nb_files}" in file.read_text(
encoding="windows-1252"
)
# test with `input dir` option provided only
# output for input-dir option
input_dir_output = output_path / "input_dir"
result = runner.invoke(
app,
[
"--input-dir",
str(original_log_dir),
"--output-dir",
str(input_dir_output),
],
)
assert result.exit_code == 0
assert result.exception is None
output_file_list = list(input_dir_output.iterdir())
assert len(output_file_list) == 3
# check files
for file in output_file_list:
assert file.name in ("all.log", "n2.log", "other.log")
assert file.stat().st_size != 0 # must contain basic execution information
assert f"Fichier(s) lu(s)\t : {nb_files}" in file.read_text(
encoding="windows-1252"
)
def test_cli_input_files_one_file(valid_log_dir: Path, output_path: Path):
mixed_logs_file = valid_log_dir / "mixed_logs.txt"
result = runner.invoke(
app, ["--input-files", str(mixed_logs_file), "--output-dir", str(output_path)]
)
assert result.exit_code == 0
assert result.exception is None
output_file_list = list(output_path.iterdir())
assert len(output_file_list) == 3
# check file
date_pattern = r"\d{1,2}/\d{1,2}/\d{4} \d{2}:\d{2}:\d{2}"
for file in output_file_list:
content = file.read_text(encoding="windows-1252")
assert re.search(f"Période\t : {date_pattern} -> {date_pattern}\n", content)
assert file.name in ("all.log", "n2.log", "other.log")
assert "Fichier(s) lu(s)\t : 1" in file.read_text(encoding="windows-1252")
assert "Nombre total d'erreur(s)\t: 0" not in content
def test_cli_empty_input_directory(tmp_path: Path, output_path: Path):
"""
Assert that CLI can run on empty directory without error and
generates reports that indicates no file read.
Args:
tmp_path: Temporary root path for testing results
output_path: Temporary path for all output generated during testing
Returns:
None
"""
input_dir = Path(tmp_path / "input_test")
input_dir.mkdir(parents=True)
result = runner.invoke(
app,
[
"--input-dir",
str(input_dir),
"--output-dir",
str(output_path),
],
)
assert result.exit_code == 0
output_file_list = list(output_path.iterdir())
assert len(output_file_list) == 3
# check files
for file in output_file_list:
content = file.read_text(encoding="windows-1252")
assert file.name in ("all.log", "n2.log", "other.log")
assert file.stat().st_size != 0 # must contain basic execution information
assert "Période : -> " in content
assert "Fichier(s) lu(s)\t : 0" in content
assert "Nombre total d'erreur(s)\t: 0" in content
def test_cli_invalid_logs(output_path: Path, invalid_log_dir: Path):
"""
On invalid logs (empty files, log from different sources than MDC LAME...),
CLI should run without error and creates report files with metrics at 0.
Args:
output_path: Temporary path for all output generated during testing
invalid_log_dir: Fixture directory of invalid logs
Returns:
None
"""
arguments = get_argument_from_files(invalid_log_dir, output_path)
results = runner.invoke(app, arguments)
assert results.exit_code == 0
assert output_path.exists()
nb_files = len(list(invalid_log_dir.iterdir()))
output_file_list = list(output_path.iterdir())
assert len(output_file_list) == 3
for file in output_file_list:
content = file.read_text(encoding="windows-1252")
assert "Période : -> " in content
assert f"Fichier(s) lu(s)\t : {nb_files}" in content
assert "Nombre total d'erreur(s)\t: 0" in content
def test_cli_valid_logs(output_path: Path, valid_log_dir: Path):
"""
On valid test, everything should work successfully. Metrics
should not be at 0.
Args:
output_path: Temporary path for all output generated during testing
valid_log_dir: Fixture directory of valid logs
Returns:
None
"""
arguments = get_argument_from_files(valid_log_dir, output_path)
results = runner.invoke(app, arguments)
assert results.exit_code == 0
assert output_path.exists()
nb_files = len(list(valid_log_dir.iterdir()))
date_pattern = r"\d{1,2}/\d{1,2}/\d{4} \d{2}:\d{2}:\d{2}"
output_file_list = list(output_path.iterdir())
assert len(output_file_list) == 3
for file in output_file_list:
content = file.read_text(encoding="windows-1252")
assert re.search(f"Période\t : {date_pattern} -> {date_pattern}\n", content)
assert f"Fichier(s) lu(s)\t : {nb_files}" in content
assert "Nombre total d'erreur(s)\t: 0" not in content
Generated
+72 -1
View File
@@ -6,6 +6,15 @@ resolution-markers = [
"python_full_version < '3.15'",
]
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "ast-serialize"
version = "0.6.0"
@@ -299,8 +308,11 @@ wheels = [
[[package]]
name = "logwatcher"
version = "0.1.0"
version = "0.0.1"
source = { editable = "." }
dependencies = [
{ name = "typer" },
]
[package.dev-dependencies]
dev = [
@@ -311,6 +323,7 @@ dev = [
]
[package.metadata]
requires-dist = [{ name = "typer", specifier = ">=0.27.2" }]
[package.metadata.requires-dev]
dev = [
@@ -320,6 +333,27 @@ dev = [
{ name = "ruff", specifier = ">=0.16.0" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy"
version = "2.3.0"
@@ -457,6 +491,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.16.0"
@@ -482,6 +529,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
@@ -536,6 +592,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typer"
version = "0.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"