From b6dab13bd15217a3ef4e7f0f6b5f107eb6be6d2c Mon Sep 17 00:00:00 2001 From: maurane glaude Date: Tue, 1 Sep 2026 14:53:18 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feat(cli):=20add=20cli=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` --- pyproject.toml | 6 +- src/logwatcher/__init__.py | 3 + src/logwatcher/cli.py | 109 +++++++++- src/logwatcher/reporter.py | 10 +- tests/test_cli.py | 423 +++++++++++++++++++++++++++++++++++++ uv.lock | 73 ++++++- 6 files changed, 613 insertions(+), 11 deletions(-) create mode 100644 tests/test_cli.py diff --git a/pyproject.toml b/pyproject.toml index c453a7f..566eafe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/logwatcher/__init__.py b/src/logwatcher/__init__.py index b53958a..1f36ff9 100644 --- a/src/logwatcher/__init__.py +++ b/src/logwatcher/__init__.py @@ -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() diff --git a/src/logwatcher/cli.py b/src/logwatcher/cli.py index adff7cb..cfe0f41 100644 --- a/src/logwatcher/cli.py +++ b/src/logwatcher/cli.py @@ -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.") diff --git a/src/logwatcher/reporter.py b/src/logwatcher/reporter.py index b23c29b..9cc065d 100644 --- a/src/logwatcher/reporter.py +++ b/src/logwatcher/reporter.py @@ -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 """) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c3724e9 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/uv.lock b/uv.lock index 6fb4f3a..d670d7e 100644 --- a/uv.lock +++ b/uv.lock @@ -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"