Compare commits
9 Commits
b83ee451a3
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b2b68e1072 | |||
| 21fda4103e | |||
| bfa5fa600b | |||
| a43afb9df7 | |||
| 24a77363d0 | |||
| b6dab13bd1 | |||
| ee118b531d | |||
| 8e98a44a7f | |||
| 9c06222f37 |
+3
-1
@@ -4,8 +4,10 @@ src/logwatcher/__pycache__/
|
||||
*.log
|
||||
|
||||
__pycache__
|
||||
docs/convention*
|
||||
|
||||
docs/conventions.yml
|
||||
docs/resultats.json
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
.coverage
|
||||
@@ -0,0 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.1] - 2026-09-02
|
||||
|
||||
### Added
|
||||
- Pipeline complet : parsing, classification N2, génération de rapports
|
||||
- CLI avec options `--input-files`, `--input-dir`, `--output-dir`, `--version`
|
||||
- Logging applicatif (jalons, compteurs)
|
||||
- Tests unitaires et d'intégration (~95% de couverture)
|
||||
- Documentation (README, usage, architecture)
|
||||
@@ -0,0 +1,83 @@
|
||||
# logwatcher
|
||||
|
||||
Logwatcher est un outil sur-mesure d'analyse et de tri automatique des logs LAME MDC pour le support N2.
|
||||
|
||||
Les techniciens N2 reçoivent quotidiennement des compte-rendus de suivi
|
||||
d'imports NOSYMAG contenant des centaines de lignes d'erreurs. La plupart
|
||||
ne les concernent pas. Logwatcher analyse ces compte-rendus, identifie
|
||||
les erreurs pertinentes pour le support N2, et génère trois rapports
|
||||
(n2, autres, tout) prêts à être transmis.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prérequis
|
||||
- Python >= 3.14
|
||||
- `uv` (recommandé) ou `pip`
|
||||
|
||||
### Procédure
|
||||
|
||||
#### uv
|
||||
```bash
|
||||
uv pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
#### pip
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Commandes
|
||||
|
||||
### Analyser un fichier de logs
|
||||
```bash
|
||||
logwatcher --input-files chemin/vers/CR_fichier.txt
|
||||
```
|
||||
|
||||
### Analyser tous les logs d'un répertoire
|
||||
```bash
|
||||
logwatcher --input-dir chemin/vers/dossier
|
||||
```
|
||||
|
||||
### Afficher la version
|
||||
```bash
|
||||
logwatcher --version
|
||||
```
|
||||
|
||||
Le guide complet se trouve dans [usage](docs/usage.md).
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
Le détail de l'architecture se trouve dans [architecture](docs/architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tests
|
||||
Les tests se trouvent dans `tests/`. Le projet est couvert à ~95% par les tests unitaires et d'intégration.
|
||||
|
||||
|
||||
### Lancer tous les tests
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
### Lancer un test spécifique (ex: `test_parser.py`)
|
||||
```bash
|
||||
pytest tests/test_parser.py
|
||||
```
|
||||
|
||||
|
||||
### Générer un rapport de couverture
|
||||
```bash
|
||||
pytest --cov=logwatcher --cov-report=term-missing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licence
|
||||
|
||||
MIT — voir [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,105 @@
|
||||
# Architecture
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Logwatcher est un outil en ligne de commande Python qui analyse des fichiers
|
||||
de logs LAME MDC, identifie les erreurs pertinentes pour le support N2, et
|
||||
génère des rapports texte.
|
||||
|
||||
Le traitement suit un pipeline linéaire :
|
||||
|
||||
```text
|
||||
fichiers de logs
|
||||
│
|
||||
▼
|
||||
parser.py lit et parse les fichiers en LogEntry
|
||||
│
|
||||
▼
|
||||
classifier.py classe chaque entrée : N2 ou hors N2
|
||||
│
|
||||
▼
|
||||
reporter.py génère les trois rapports (all, n2, other)
|
||||
│
|
||||
▼
|
||||
rapports .log
|
||||
```
|
||||
|
||||
## Structure du package
|
||||
|
||||
```text
|
||||
src/logwatcher/
|
||||
├── __init__.py # expose __version__
|
||||
├── __main__.py # point d'entrée : python -m logwatcher
|
||||
├── cli.py # interface en ligne de commande (Typer)
|
||||
├── config.py # constantes : chemins, formats
|
||||
├── logging_config.py # configuration du logging
|
||||
├── models.py # LogEntry : structure de données
|
||||
├── parser.py # lecture et parsing des fichiers
|
||||
├── classifier.py # classification N2 / hors N2
|
||||
└── reporter.py # génération des rapports texte
|
||||
```
|
||||
|
||||
### Modules
|
||||
#### cli.py — point d'entrée
|
||||
|
||||
* Définit `app = typer.Typer()` et la commande principale.
|
||||
* Valide les arguments (`--input-files` / `--input-dir` mutuellement exclusifs).
|
||||
* Orchestre le pipeline : parsing → classification → rapports.
|
||||
* Gère les erreurs utilisateur (`BadParameter`, code de sortie 2) et
|
||||
* les erreurs d'exécution (code de sortie 1).
|
||||
* Expose `--version` (eager, affiche et quitte).
|
||||
|
||||
#### models.py — LogEntry
|
||||
|
||||
Dataclass immuable (par convention) représentant une ligne de log parsée.
|
||||
|
||||
Champs principaux :
|
||||
|
||||
* `server_ip`, `mdc_server_name` : origine du log
|
||||
* `store_name` : magasin concerné
|
||||
* `start_time`, error_time : horodatages
|
||||
* `error_message` : message d'erreur complet
|
||||
* `error_name` : nom du pattern N2 correspondant (rempli par le classifier)
|
||||
* `raw_line` : ligne brute d'origine
|
||||
* `ligne` : numéro de ligne dans le fichier source
|
||||
|
||||
LogEntry est créée uniquement par le parser. Le classifier l'enrichit
|
||||
(remplit `error_name`). Le modèle lui-même ne contient pas de logique métier.
|
||||
[`parser.py`]() — lecture et parsing
|
||||
|
||||
* `LOG_PATTERN` : expression régulière du format d'une ligne LAME MDC.
|
||||
* `parse_log_file()` : lit un fichier, retourne list[LogEntry].
|
||||
* Les lignes non conformes (système, en-têtes, corrompues) sont ignorées silencieusement.
|
||||
|
||||
#### classifier.py — classification
|
||||
|
||||
* N2_PATTERNS : dictionnaire des motifs d'erreurs pertinents pour le N2, fournis par les techniciens.
|
||||
* classify_log_entries() : sépare les entrées en deux listes (relevant, irrelevant) et remplit entry.error_name pour les entrées pertinentes.
|
||||
|
||||
#### reporter.py — rapports
|
||||
|
||||
* Templates texte (`string.Template`) : `BASE_TEMPLATE`, `N2_SUPPORT_TEMPLATE`, `OTHER_TEMPLATE`, `ERROR_TEMPLATE`.
|
||||
* `build_reports()` : construit les trois rapports (`dict n2, other, all`).
|
||||
* `write_log_report()` : écrit les rapports sur disque en Windows-1252.
|
||||
* `_get_period()` : calcule les bornes min/max des error_time.
|
||||
|
||||
#### logging_config.py — logging
|
||||
|
||||
* `setup_logging()` : configure le logger racine.
|
||||
* Handler console (`INFO`, ou `DEBUG` si verbose) + handler fichier.
|
||||
* Le fichier de log `logwatcher.log` est horodaté et écrit dans le dossier de sortie.
|
||||
|
||||
#### config.py — constantes
|
||||
|
||||
* Chemins (`OUTPUT_PATH`, `FIXTURE_PATH` ...).
|
||||
* Formats de date (`DATETIME_FORMAT`).
|
||||
|
||||
## Dépendances
|
||||
|
||||
| Package | Rôle |
|
||||
|---------|------|
|
||||
| `typer` | Interface en ligne de commande |
|
||||
| `pytest` | Tests (dev) |
|
||||
| `pytest-cov` | Couverture (dev) |
|
||||
| `ruff` | Linting/formatage (dev) |
|
||||
| `mypy` | Typage statique (dev) |
|
||||
-5831
File diff suppressed because one or more lines are too long
+142
@@ -0,0 +1,142 @@
|
||||
# Guide d'utilisation
|
||||
|
||||
## Introduction
|
||||
|
||||
Logwatcher analyse les comptes-rendus de suivi d'imports NOSYMAG (fichiers
|
||||
`CR_*.txt` et autres logs LAME MDC) pour identifier les erreurs pertinentes
|
||||
pour le support N2. Il génère trois rapports : un pour les erreurs N2, un
|
||||
pour les erreurs hors périmètre N2, et un rapport complet.
|
||||
|
||||
---
|
||||
|
||||
## Entrées
|
||||
|
||||
### Analyser un fichier de logs
|
||||
|
||||
```bash
|
||||
logwatcher --input-files chemin/vers/CR_fichier.txt
|
||||
```
|
||||
|
||||
### Analyser plusieurs fichiers de logs
|
||||
L'option `--input-files` accepte plusieurs fichiers. Répéter l'option pour
|
||||
chaque fichier :
|
||||
|
||||
```bash
|
||||
logwatcher --input-files fichier1.txt --input-files fichier2.txt
|
||||
```
|
||||
|
||||
### Analyser tous les logs d'un répertoire
|
||||
|
||||
```bash
|
||||
logwatcher --input-dir chemin/vers/dossier
|
||||
```
|
||||
|
||||
L'option `--input-dir` analyse tous les fichiers `.log`, `.txt` ou sans
|
||||
extension contenus dans le répertoire. Les autres fichiers sont ignorés.
|
||||
|
||||
> ### Règle d'exclusivité
|
||||
> `--input-files` et `--input-dir` sont mutuellement exclusifs. Fournir
|
||||
les deux options lève une erreur et le traitement est interrompu. Ne fournir
|
||||
aucune des deux lève également une erreur.
|
||||
|
||||
## Sorties
|
||||
|
||||
### Emplacement des rapports
|
||||
|
||||
Par défaut, les rapports sont écrits dans le répertoire `output/` du dossier
|
||||
courant. Un autre emplacement peut être fourni avec `--output-dir` :
|
||||
|
||||
```bash
|
||||
logwatcher --input-dir chemin/vers/dossier --output-dir chemin/vers/sortie
|
||||
```
|
||||
|
||||
### Les rapports générés
|
||||
|
||||
Chaque analyse produit trois rapports différents : `all.log`, `n2.log` et `other.log`.
|
||||
|
||||
- `all.log` : Toutes les erreurs analysées, N2 et hors N2.
|
||||
- `n2.log` : Uniquement les erreurs pertinentes pour le support N2.
|
||||
- `other.log` : Uniquement les erreurs hors périmètre N2
|
||||
|
||||
|
||||
#### Contenu d'un rapport
|
||||
|
||||
Chaque rapport contient un en-tête avec la période analysée, le nombre de
|
||||
fichiers lus et le nombre total d'erreurs, suivi du détail des erreurs.
|
||||
Un rapport se présente ainsi :
|
||||
|
||||
```text
|
||||
RAPPORT D'ANALYSE DE LOGS
|
||||
=========================
|
||||
Période : 01/09/2026 11:07:53 -> 01/09/2026 15:04:18
|
||||
Fichier(s) lu(s) : 1
|
||||
Nombre total d'erreur(s) : 3
|
||||
|
||||
=================================
|
||||
ERREURS SUPPORT N2
|
||||
=================================
|
||||
Nombre d'erreurs : 2
|
||||
|
||||
[1] BL_AUTO_BESTSELLER_REJECTED
|
||||
Magasin: DUMAS-DELAGE
|
||||
MDC: MDC_340
|
||||
Serveur: 192.168.13.23
|
||||
Heure: 28/07/2026 08:05:27
|
||||
Raison: Erreur : 1099_2622609667_DESADV : / 00098/000 ... ne correspond
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--output-dir` : Répertoire de sortie des rapports. Par défaut : `output/`.
|
||||
```bash
|
||||
logwatcher --input-dir logs/ --output-dir rapports/2026-09-01/
|
||||
```
|
||||
|
||||
- `--version` : Affiche la version du package et quitte.
|
||||
```bash
|
||||
logwatcher --version
|
||||
# logwatcher version: 0.0.1
|
||||
```
|
||||
|
||||
## Cas particuliers
|
||||
|
||||
### Fichier vide
|
||||
|
||||
Un fichier vide est lu sans erreur. Les trois rapports sont générés avec `Nombre total d'erreur(s) : 0`.
|
||||
|
||||
|
||||
### Répertoire vide
|
||||
|
||||
Un répertoire vide est traité sans erreur. Les trois rapports sont générés
|
||||
avec `Fichier(s) lu(s) : 0 et Nombre total d'erreur(s) : 0`.
|
||||
|
||||
|
||||
### Logs non reconnus
|
||||
|
||||
Les lignes qui ne correspondent pas au format LAME MDC (lignes système,
|
||||
en-têtes de répertoire, lignes corrompues) sont ignorées silencieusement
|
||||
et ne produisent pas d'entrée dans les rapports.
|
||||
|
||||
|
||||
### Encodage des fichiers
|
||||
|
||||
Les fichiers d'entrée sont lus en Windows-1252 (encodage natif des logs
|
||||
LAME MDC). Les rapports générés sont également écrits en Windows-1252.
|
||||
|
||||
## Exemple complet
|
||||
|
||||
Analyser tous les logs d'un répertoire et écrire les rapports dans un
|
||||
dossier dédié :
|
||||
```bash
|
||||
logwatcher --input-dir logs/2026-09-01/ --output-dir rapports/2026-09-01/
|
||||
```
|
||||
|
||||
Après exécution, les fichiers suivants sont créés dans `rapports/2026-09-01/` :
|
||||
```text
|
||||
all.log # toutes les erreurs
|
||||
n2.log # erreurs pertinentes pour le N2
|
||||
other.log # erreurs hors périmètre N2
|
||||
```
|
||||
|
||||
Le rapport `n2.log` peut ensuite être transmis aux techniciens N2, et
|
||||
`other.log` aux équipes concernées par les autres erreurs.
|
||||
+5
-4
@@ -1,11 +1,11 @@
|
||||
[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"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"pytest-cov>=7.1.0",
|
||||
"typer>=0.27.2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -13,10 +13,11 @@ dev = [
|
||||
"ruff>=0.16.0",
|
||||
"mypy>=2.3.0",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-cov>=7.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
logwatcher = "logwatcher.app:app"
|
||||
logwatcher = "logwatcher.cli:app"
|
||||
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from logwatcher.config import *
|
||||
from logwatcher.logging import setup_logging
|
||||
from importlib.metadata import version
|
||||
|
||||
from logwatcher.config import *
|
||||
from logwatcher.logging_config import setup_logging
|
||||
|
||||
__version__ = version("logwatcher")
|
||||
logger = setup_logging()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from logwatcher.app import app
|
||||
from logwatcher.cli import app
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from logwatcher import logger
|
||||
|
||||
|
||||
def app():
|
||||
logger.info("logwatcher starts its work.")
|
||||
logger.info('logwatcher has finished its task.')
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
|
||||
from logwatcher import logger
|
||||
from logwatcher.models import LogEntry
|
||||
|
||||
# ============================================================
|
||||
@@ -35,37 +36,50 @@ N2_PATTERNS = {
|
||||
"PARTIAL_INTEGRATION": r"Erreur : (?P<bl_number>(?P<sap_code>\d{4})_(?P<order_number>\w+)_DESADV) : ISC ERROR CODE:(?P<isc_error_code>\d+) \(attention intégration partielle du BL ou d'une commande\)",
|
||||
"SAME_LINE_EXCEPT_DISCOUNT": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+))(?P<brand_code>\w{3})) / SetCOMBCDE -> OrderNumber : \d+ / Une commande ne peut pas contenir plusieurs lignes avec le même modèle, la même collection, la même date de livraison et avoir des remises différentes",
|
||||
"MULTIPLE_ROWS_IN_SINGLETON_SELECT": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+))(?P<brand_code>\w{3})) / SizeRange (?P<size_range>\d+\.\d+) / SetMrkFourn -> (?: \[[^\]]+\])+multiple rows in singleton select",
|
||||
|
||||
# low priority errors
|
||||
"SIZE_NOT_FOUND": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING (\d+ \w+) / SizeRange (?P<size_range>\d+\.\d+) - Columnx \d+ / Taille Inexistante",
|
||||
"UNSYNCED_ORD_SD_FILES": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+)(?P<brand_code>\w{3}))) / OrderNumber : \d+ / Le modèle n'existe pas dans le fichier SD correspondant",
|
||||
"CODE_MODEL_SIZE_OUT_OF_BOUND": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING [\w ]+ \w+ / SetArticle -> SizeRange \d+\.\d+ / (?: \[[^\]]+\])+-\w+\. Données trop volumineuses pour la variable (?: \[[^\]]+\]). Long. max. = \[\d+\], long. réelle = \[\d+\] Conseil : attribuez une valeur supérieure au paramètre TFDParam.Size",
|
||||
"USER_NOT_CREATED_ON_NOSYMAG": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR / OrderNumber : \d+ / Utilisateur : configuration incorrecte \(valeur 0\) - vérifier dans IF_Config",
|
||||
"INCORRECT_MDC_XML_FILES": r"Exception : Impossible de convertir le variant de type \(Null\) en type \(OleStr\)"
|
||||
"INCORRECT_MDC_XML_FILES": r"Exception : Impossible de convertir le variant de type \(Null\) en type \(OleStr\)",
|
||||
}
|
||||
|
||||
# compiled all patterns
|
||||
_COMPILED_N2_PATTERNS = {error_name: re.compile(pattern) for error_name, pattern in N2_PATTERNS.items()}
|
||||
_COMPILED_N2_PATTERNS = {
|
||||
error_name: re.compile(pattern) for error_name, pattern in N2_PATTERNS.items()
|
||||
}
|
||||
|
||||
def _match_n2_pattern(log_entry: LogEntry) -> str|None:
|
||||
|
||||
def _match_n2_pattern(log_entry: LogEntry) -> str | None:
|
||||
for error_code, pattern in _COMPILED_N2_PATTERNS.items():
|
||||
if pattern.search(log_entry.error_message):
|
||||
return error_code
|
||||
return None
|
||||
|
||||
def classify_log_entries(log_entries: list[LogEntry]) -> tuple[list[LogEntry], list[LogEntry]]:
|
||||
|
||||
def classify_log_entries(
|
||||
log_entries: list[LogEntry],
|
||||
) -> tuple[list[LogEntry], list[LogEntry]]:
|
||||
"""
|
||||
Separate logs that require N2 intervention from those that don't.
|
||||
"""
|
||||
relevant_log_entries = []
|
||||
irrelevant_log_entries = []
|
||||
|
||||
logger.info("\tclassification job started.")
|
||||
for log_entry in log_entries:
|
||||
error_name = _match_n2_pattern(log_entry)
|
||||
if error_name:
|
||||
logger.info(
|
||||
f"\t\tError '{error_name}' associated with log entry '{log_entry.error_message}'. This is a n2 log entry"
|
||||
)
|
||||
log_entry.error_name = error_name
|
||||
relevant_log_entries.append(log_entry)
|
||||
else:
|
||||
irrelevant_log_entries.append(log_entry)
|
||||
|
||||
logger.info(
|
||||
f"\t\tn2 logs: {len(relevant_log_entries)}, other logs: {len(irrelevant_log_entries)}"
|
||||
)
|
||||
logger.info("\tclassification job completed.")
|
||||
return relevant_log_entries, irrelevant_log_entries
|
||||
@@ -0,0 +1,124 @@
|
||||
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 version_callback(value: bool):
|
||||
"""
|
||||
Print the version of package.
|
||||
Stop the CLI after printing version.
|
||||
|
||||
Args:
|
||||
value: True if '--version' is provided, False otherwise.
|
||||
"""
|
||||
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).
|
||||
|
||||
Args:
|
||||
path: Path of a file
|
||||
|
||||
Returns:
|
||||
True if file has .txt or .log extension or extensionless
|
||||
False otherwise.
|
||||
"""
|
||||
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 started ============")
|
||||
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. 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")
|
||||
|
||||
logger.info(f"reports will be located at '{output_dir.cwd()}'")
|
||||
|
||||
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 ended ============")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
+15
-10
@@ -45,20 +45,24 @@ def parse_log_line(log_line: str) -> LogEntry | None:
|
||||
"""
|
||||
match = LOG_PATTERN.search(log_line)
|
||||
if not match:
|
||||
logger.debug(f"Unrecognize format. Ignoring line {log_line}.")
|
||||
return None
|
||||
|
||||
data = match.groupdict()
|
||||
return LogEntry(
|
||||
server_ip=data["server_ip"],
|
||||
mdc_server_name=data["mdc_server_name"],
|
||||
start_time=datetime.strptime(data["start_time"], DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE),
|
||||
start_time=datetime.strptime(data["start_time"], DATETIME_FORMAT).astimezone(
|
||||
FRENCH_TIMEZONE
|
||||
),
|
||||
store_name=data["store_name"],
|
||||
error_time=datetime.strptime(data["error_time"], DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE),
|
||||
error_time=datetime.strptime(data["error_time"], DATETIME_FORMAT).astimezone(
|
||||
FRENCH_TIMEZONE
|
||||
),
|
||||
error_message=data["error_message"],
|
||||
raw_line=log_line
|
||||
raw_line=log_line,
|
||||
)
|
||||
|
||||
|
||||
def parse_log_file(log_file_path: Path) -> list[LogEntry]:
|
||||
"""Transforms the content of a log file into a list of LogEntry.
|
||||
Uses `parse_log_line` to parse each line of the choosen log file.
|
||||
@@ -72,17 +76,18 @@ def parse_log_file(log_file_path: Path) -> list[LogEntry]:
|
||||
"""
|
||||
log_entry_list: list[LogEntry] = []
|
||||
|
||||
with open(log_file_path, 'r', encoding="windows-1252") as log_file:
|
||||
with open(log_file_path, "r", encoding="windows-1252") as log_file:
|
||||
logger.info(f"\tparsing log file '{log_file_path.name}' started.")
|
||||
for index, log_line in enumerate(log_file):
|
||||
try:
|
||||
log_entry = parse_log_line(log_line)
|
||||
except ValueError as error:
|
||||
logger.error(f"Error while parsing the log file {log_file.name} at {log_line}: {error}")
|
||||
logger.error(
|
||||
f"\t\tline {index}: error while parsing the log file '{log_file.name}': {error}"
|
||||
)
|
||||
else:
|
||||
if log_entry:
|
||||
log_entry_list.append(log_entry)
|
||||
logger.debug(f"Line {index} was successfully parsed.")
|
||||
else:
|
||||
logger.debug(f"Line {index} is empty. Skip.")
|
||||
|
||||
logger.info(f"\t\t{len(log_entry_list)} lines were parsed.")
|
||||
logger.info("\tparsing completed.")
|
||||
return log_entry_list
|
||||
+85
-44
@@ -1,7 +1,8 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
from logwatcher import logger
|
||||
from logwatcher.config import DATETIME_FORMAT, RESULT_PATH
|
||||
from logwatcher.models import LogEntry
|
||||
|
||||
@@ -9,31 +10,33 @@ from logwatcher.models import LogEntry
|
||||
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
|
||||
Période\t : $start_date -> $end_date
|
||||
Fichier(s) lu(s)\t : $nb_files
|
||||
Nombre total d'erreur(s)\t: $nb_errors
|
||||
$sub_content
|
||||
""")
|
||||
|
||||
N2_SUPPORT_TEMPLATE = Template("""
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
=========================
|
||||
ERREURS SUPPORT N2
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Nombre d'erreurs\t : $nb_errors
|
||||
=========================
|
||||
Nombre d'erreur(s)\t : $nb_errors
|
||||
$error_list
|
||||
""")
|
||||
|
||||
OTHER_TEMPLATE = Template("""
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
=========================
|
||||
AUTRES ERREURS
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Nombre d'erreurs\t : $nb_errors
|
||||
=========================
|
||||
Nombre d'erreur(s)\t : $nb_errors
|
||||
$error_list
|
||||
""")
|
||||
|
||||
ERROR_TEMPLATE = Template("""
|
||||
[$index] $error_name
|
||||
\tMagasin: $store_name
|
||||
\tMDC: $mdc
|
||||
\tIP du MDC: $server_ip
|
||||
\tHeure: $error_time
|
||||
\tRaison: $error_message
|
||||
""")
|
||||
@@ -45,45 +48,71 @@ def _get_period(entries: list[LogEntry]) -> tuple[str, str]:
|
||||
times: list[datetime] = [e.error_time for e in entries]
|
||||
return min(times).strftime(DATETIME_FORMAT), max(times).strftime(DATETIME_FORMAT)
|
||||
|
||||
def write_log_report(relevant: list[LogEntry], irrelevant: list[LogEntry], nb_files: int):
|
||||
|
||||
def write_log_report(
|
||||
relevant: list[LogEntry],
|
||||
irrelevant: list[LogEntry],
|
||||
nb_files: int,
|
||||
output_dir: Path = RESULT_PATH,
|
||||
) -> None:
|
||||
"""
|
||||
Write each relevant, irrelevant and general reports in their respective output
|
||||
file.
|
||||
Use the range date of relevant and irrelevant lists to get the period of time
|
||||
the logs were generated.
|
||||
|
||||
Args:
|
||||
relevant: List of n2 log entry
|
||||
irrelevant: List of not n2 log entry
|
||||
nb_files: Number of files scanned
|
||||
output_dir: Location where all reports will be written
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
end_date, start_date = _get_period(relevant + irrelevant)
|
||||
logger.info("\twriting reports job started.")
|
||||
start_date, end_date = _get_period(relevant + irrelevant)
|
||||
logger.info(f"\t\tperiod found: {start_date} -> {end_date}")
|
||||
|
||||
reports_dict = build_reports(relevant, irrelevant, start_date, end_date, nb_files)
|
||||
|
||||
for target, content in reports_dict.items():
|
||||
target_file = RESULT_PATH / str(target + ".log")
|
||||
target_file.write_text(content, encoding="windows-1252")
|
||||
for target in reports_dict:
|
||||
logger.info(f"\t\tgenerating report '{target}'...")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(f"\t\t\t{output_dir} exists.")
|
||||
target_file = output_dir / str(target + ".log")
|
||||
target_file.write_text(reports_dict[target], encoding="windows-1252")
|
||||
logger.debug(f"\t\t\treport was written in {target_file.name}")
|
||||
logger.info(f"\t\treport '{target}' was generated.")
|
||||
logger.info("\twriting reports job completed.")
|
||||
|
||||
|
||||
def generate_json_report(relevant: list[LogEntry]) -> str: # TODO: put it in a serializer module
|
||||
"""
|
||||
Serialize from a Python LogEntry object to a JSON object.
|
||||
Used in the Ivanti API calls
|
||||
"""
|
||||
return json.dumps(relevant)
|
||||
|
||||
|
||||
def build_reports(relevant: list[LogEntry], irrelevant: list[LogEntry], start_date, end_date, nb_files) -> dict[str, str]:
|
||||
def build_reports(
|
||||
relevant: list[LogEntry], irrelevant: list[LogEntry], start_date, end_date, nb_files
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Build the three output reports: n2, other, and all.
|
||||
"all" report contains both "n2" and "other" sections.
|
||||
"n2" and "other" are two unique sections.
|
||||
|
||||
Args:
|
||||
relevant: List of n2 log entry
|
||||
irrelevant: List of not n2 log entry
|
||||
start_date: Date of the oldest log entry in relevant + irrelevant list
|
||||
end_date: Date of the newest log entry in relevant + irrelevant list
|
||||
nb_files: number of files scanned
|
||||
Returns:
|
||||
Dictionary of reports in string format
|
||||
"""
|
||||
logger.info("\t\tbuilding reports job started.")
|
||||
relevant_report = _render_target_report(
|
||||
log_entries=relevant,
|
||||
target_template=N2_SUPPORT_TEMPLATE
|
||||
log_entries=relevant, target_template=N2_SUPPORT_TEMPLATE
|
||||
)
|
||||
|
||||
irrelevant_report = _render_target_report(
|
||||
log_entries=irrelevant,
|
||||
target_template=OTHER_TEMPLATE
|
||||
log_entries=irrelevant, target_template=OTHER_TEMPLATE
|
||||
)
|
||||
logger.info("\t\t\tn2 and other reports were generated.")
|
||||
logger.info("\t\tbuilding reports job completed.")
|
||||
|
||||
return {
|
||||
"n2": BASE_TEMPLATE.substitute(
|
||||
@@ -106,14 +135,38 @@ def build_reports(relevant: list[LogEntry], irrelevant: list[LogEntry], start_da
|
||||
nb_files=nb_files,
|
||||
nb_errors=len(relevant + irrelevant),
|
||||
sub_content=relevant_report + "\n" + irrelevant_report,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# build templates
|
||||
def _render_target_report(
|
||||
log_entries: list[LogEntry], target_template: Template
|
||||
) -> str:
|
||||
"""
|
||||
Render report using either N2_SUPPORT_TEMPLATE or OTHER_TEMPLATE.
|
||||
Call _render_entries() and include rendered log entries in the
|
||||
new report.
|
||||
|
||||
Args:
|
||||
log_entries: List of log entries
|
||||
target_template: Template to use for N2_SUPPORT_TEMPLATE
|
||||
Returns:
|
||||
content of report in string format
|
||||
"""
|
||||
error_list = _render_entries(log_entries)
|
||||
return target_template.substitute(nb_errors=len(log_entries), error_list=error_list)
|
||||
|
||||
|
||||
def _render_entries(log_entries: list[LogEntry]) -> str:
|
||||
"""
|
||||
Render a list of log entries using the `ERROR_TEMPLATE`.
|
||||
Used in generated report files in the `output` directory
|
||||
|
||||
Args:
|
||||
log_entries: List of log entries
|
||||
Returns:
|
||||
error report in string format
|
||||
"""
|
||||
error_list = []
|
||||
for index, log_entry in enumerate(log_entries, start=1):
|
||||
@@ -123,21 +176,9 @@ def _render_entries(log_entries: list[LogEntry]) -> str:
|
||||
error_name=log_entry.error_name,
|
||||
store_name=log_entry.store_name,
|
||||
error_time=log_entry.get_error_time(),
|
||||
error_message=log_entry.error_message
|
||||
error_message=log_entry.error_message,
|
||||
mdc=log_entry.mdc_server_name,
|
||||
server_ip=log_entry.server_ip,
|
||||
)
|
||||
)
|
||||
return "".join(error_list)
|
||||
|
||||
|
||||
# build templates
|
||||
def _render_target_report(log_entries: list[LogEntry], target_template) -> str:
|
||||
"""
|
||||
Render report using either N2_SUPPORT_TEMPLATE or OTHER_TEMPLATE.
|
||||
Call _render_entries() and include rendered log entries in the
|
||||
new report.
|
||||
"""
|
||||
error_list = _render_entries(log_entries)
|
||||
return target_template.substitute(
|
||||
nb_errors=len(log_entries),
|
||||
error_list=error_list
|
||||
)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from logwatcher.config import FIXTURE_PATH
|
||||
from logwatcher.logging import setup_logging
|
||||
from logwatcher.logging_config import setup_logging
|
||||
|
||||
# original log directory
|
||||
ORIGINAL_LOG_DIR = FIXTURE_PATH / "original_logs"
|
||||
|
||||
Vendored
@@ -104,7 +104,7 @@ def test_classify_log_entries_none_relevant(valid_log_dir: Path):
|
||||
"""
|
||||
Must return an empty relevant log entry list
|
||||
"""
|
||||
log_file = valid_log_dir / "no_relevant_error.txt"
|
||||
log_file = valid_log_dir / "no_relevant_logs.txt"
|
||||
log_entries = parse_log_file(log_file)
|
||||
|
||||
# all entries are relevant
|
||||
|
||||
@@ -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
|
||||
@@ -14,7 +14,7 @@ from logging import DEBUG, ERROR, INFO, WARNING, FileHandler, StreamHandler, get
|
||||
import pytest
|
||||
|
||||
from logwatcher import logger
|
||||
from logwatcher.logging import _setup_formatter, _setup_handler, setup_logging
|
||||
from logwatcher.logging_config import _setup_formatter, _setup_handler, setup_logging
|
||||
|
||||
|
||||
def test_setup_handler(tmp_log_file):
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from logwatcher.classifier import classify_log_entries
|
||||
from logwatcher.config import DATETIME_FORMAT, FIXTURE_PATH
|
||||
from logwatcher.models import LogEntry
|
||||
from logwatcher.parser import parse_log_file
|
||||
from logwatcher.reporter import (
|
||||
N2_SUPPORT_TEMPLATE,
|
||||
OTHER_TEMPLATE,
|
||||
_get_period,
|
||||
_render_entries,
|
||||
_render_target_report,
|
||||
build_reports,
|
||||
write_log_report,
|
||||
)
|
||||
|
||||
# path of all CR_* files used in `get_all_log_entries_fixture`
|
||||
CR_LOG_FILES = list(FIXTURE_PATH.glob("*/CR_*"))
|
||||
|
||||
|
||||
def _get_classified_log_entries(
|
||||
log_file: Path,
|
||||
) -> tuple[list[LogEntry], list[LogEntry]]:
|
||||
"""Parse a log file and split its entries into relevant and irrelevant lists."""
|
||||
log_entries = parse_log_file(log_file)
|
||||
return classify_log_entries(log_entries)
|
||||
|
||||
|
||||
@pytest.fixture(name="nb_files")
|
||||
def get_nb_log_files_used_fixture():
|
||||
"""Return the number of CR_* log files used by the other fixtures."""
|
||||
return len(CR_LOG_FILES)
|
||||
|
||||
|
||||
@pytest.fixture(name="get_all_log_entries")
|
||||
def get_all_log_entries_fixture() -> tuple[list[LogEntry], list[LogEntry]]:
|
||||
"""Parse and classify every CR_* fixture, returning (relevant, irrelevant)."""
|
||||
all_entries = []
|
||||
for log_file in CR_LOG_FILES:
|
||||
all_entries.extend(parse_log_file(log_file))
|
||||
relevant_list, irrelevant_list = classify_log_entries(all_entries)
|
||||
return relevant_list, irrelevant_list
|
||||
|
||||
|
||||
@pytest.fixture(name="get_only_relevant_log_entries")
|
||||
def get_only_relevant_log_entries_fixture(valid_log_dir: Path):
|
||||
"""Return (relevant, irrelevant) for a file containing only N2-relevant errors."""
|
||||
log_file = valid_log_dir / "only_relevant_logs.txt"
|
||||
return _get_classified_log_entries(log_file)
|
||||
|
||||
|
||||
@pytest.fixture(name="get_only_irrelevant_log_entries")
|
||||
def get_only_irrelevant_log_entries_fixture(valid_log_dir: Path):
|
||||
"""Return (relevant, irrelevant) for a file containing only non-N2 errors."""
|
||||
log_file = valid_log_dir / "no_relevant_logs.txt"
|
||||
return _get_classified_log_entries(log_file)
|
||||
|
||||
|
||||
def test_get_period_empty_list():
|
||||
"""An empty entry list yields an empty (start, end) period."""
|
||||
assert _get_period([]) == ("", "")
|
||||
|
||||
|
||||
def test_get_period_format(
|
||||
get_all_log_entries: tuple[list[LogEntry], list[LogEntry]],
|
||||
):
|
||||
"""Period bounds are formatted according to the configured datetime format."""
|
||||
period = _get_period(get_all_log_entries[0] + get_all_log_entries[1])
|
||||
for period_bound in period:
|
||||
try:
|
||||
assert datetime.strptime(period_bound, DATETIME_FORMAT) # noqa: DTZ007
|
||||
except ValueError:
|
||||
pytest.fail(f"Wrong date format for {period_bound}")
|
||||
|
||||
|
||||
def test_render_entries_all(get_all_log_entries: tuple[list[LogEntry], list[LogEntry]]):
|
||||
"""Rendered entries expose every useful field but never the raw line."""
|
||||
for log_entries in get_all_log_entries:
|
||||
error_report = _render_entries(log_entries)
|
||||
if log_entries:
|
||||
for index, log_entry in enumerate(log_entries, start=1):
|
||||
assert f"[{index}]" in error_report
|
||||
assert log_entry.error_name in error_report
|
||||
assert log_entry.store_name in error_report
|
||||
assert log_entry.get_error_time() in error_report
|
||||
try:
|
||||
assert datetime.strptime( # noqa: DTZ007
|
||||
log_entry.get_error_time(), DATETIME_FORMAT
|
||||
)
|
||||
except ValueError:
|
||||
pytest.fail(f"Wrong date format for {log_entry}")
|
||||
assert log_entry.error_message in error_report
|
||||
assert log_entry.server_ip in error_report
|
||||
assert log_entry.mdc_server_name in error_report
|
||||
# must not be in report file
|
||||
assert log_entry.raw_line not in error_report
|
||||
elif not log_entries:
|
||||
assert error_report == ""
|
||||
else:
|
||||
pytest.fail("`log_entries` must be defined")
|
||||
|
||||
|
||||
def test_render_entries_relevant(
|
||||
get_only_relevant_log_entries: tuple[list[LogEntry], list[LogEntry]],
|
||||
):
|
||||
"""Only-relevant input renders a non-empty relevant block and an empty other block."""
|
||||
error_report_relevant = _render_entries(get_only_relevant_log_entries[0])
|
||||
error_report_irrelevant = _render_entries(get_only_relevant_log_entries[1])
|
||||
assert error_report_relevant != "", error_report_irrelevant == ""
|
||||
|
||||
|
||||
def test_render_entries_irrelevant(
|
||||
get_only_irrelevant_log_entries: tuple[list[LogEntry], list[LogEntry]],
|
||||
):
|
||||
"""Only-irrelevant input renders an empty relevant block and a non-empty other block."""
|
||||
error_report_relevant = _render_entries(get_only_irrelevant_log_entries[0])
|
||||
error_report_irrelevant = _render_entries(get_only_irrelevant_log_entries[1])
|
||||
assert error_report_relevant == "", error_report_irrelevant != ""
|
||||
|
||||
|
||||
def test_render_target_report_relevant(
|
||||
get_only_relevant_log_entries: tuple[list[LogEntry], list[LogEntry]],
|
||||
):
|
||||
"""The N2 section header, count, and rendered entries are all present."""
|
||||
relevant = get_only_relevant_log_entries[0]
|
||||
target_report = _render_target_report(relevant, N2_SUPPORT_TEMPLATE)
|
||||
assert "ERREURS SUPPORT N2" in target_report
|
||||
assert target_report.find("Nombre d'erreur(s)\t : " + str(len(relevant))) != -1
|
||||
assert _render_entries(relevant) in target_report
|
||||
|
||||
|
||||
def test_render_target_report_irrelevant(
|
||||
get_only_irrelevant_log_entries: tuple[list[LogEntry], list[LogEntry]],
|
||||
):
|
||||
"""The "other" section header, count, and rendered entries are all present."""
|
||||
irrelevant = get_only_irrelevant_log_entries[1]
|
||||
target_report = _render_target_report(irrelevant, OTHER_TEMPLATE)
|
||||
assert "AUTRES ERREURS" in target_report
|
||||
assert target_report.find("Nombre d'erreur(s)\t : " + str(len(irrelevant))) != -1
|
||||
assert _render_entries(irrelevant) in target_report
|
||||
|
||||
|
||||
def test_render_target_report_all(
|
||||
get_all_log_entries: tuple[list[LogEntry], list[LogEntry]],
|
||||
):
|
||||
"""Each target template renders the expected section header."""
|
||||
relevant, irrelevant = get_all_log_entries
|
||||
relevant_report = _render_target_report(relevant, N2_SUPPORT_TEMPLATE)
|
||||
irrelevant_report = _render_target_report(irrelevant, OTHER_TEMPLATE)
|
||||
assert "ERREURS SUPPORT N2" in relevant_report
|
||||
assert "AUTRES ERREURS" in irrelevant_report
|
||||
|
||||
|
||||
def test_build_reports(
|
||||
get_all_log_entries: tuple[list[LogEntry], list[LogEntry]], nb_files
|
||||
):
|
||||
"""The three reports are built with correct headers, counts, and section separation."""
|
||||
start_date, end_date = _get_period(get_all_log_entries[0] + get_all_log_entries[1])
|
||||
reports = build_reports(
|
||||
get_all_log_entries[0], get_all_log_entries[1], start_date, end_date, nb_files
|
||||
)
|
||||
|
||||
assert "all" in reports and "n2" in reports and "other" in reports
|
||||
assert reports["all"].find(f"Période\t : {start_date} -> {end_date}") != -1
|
||||
assert reports["all"].find(f"Fichier(s) lu(s)\t : {nb_files}") != -1
|
||||
assert (
|
||||
reports["all"].find(
|
||||
f"Nombre total d'erreur(s)\t: {len(get_all_log_entries[0] + get_all_log_entries[1])}"
|
||||
)
|
||||
!= -1
|
||||
)
|
||||
assert "ERREURS SUPPORT N2" in reports["all"] or "AUTRES ERREURS" in reports["all"]
|
||||
assert (
|
||||
"AUTRES ERREURS" not in reports["n2"] and "ERREURS SUPPORT N2" in reports["n2"]
|
||||
)
|
||||
assert (
|
||||
"ERREURS SUPPORT N2" not in reports["other"]
|
||||
and "AUTRES ERREURS" in reports["other"]
|
||||
)
|
||||
assert (
|
||||
reports["n2"].find(f"Nombre total d'erreur(s)\t: {len(get_all_log_entries[0])}")
|
||||
!= -1
|
||||
)
|
||||
assert (
|
||||
reports["other"].find(
|
||||
f"Nombre total d'erreur(s)\t: {len(get_all_log_entries[1])}"
|
||||
)
|
||||
!= -1
|
||||
)
|
||||
|
||||
|
||||
def _test_write_log_report(
|
||||
log_entries_tuple: tuple[list[LogEntry], list[LogEntry]],
|
||||
nb_files: int,
|
||||
target_name_empty: str,
|
||||
output_dir: Path,
|
||||
):
|
||||
"""Write the reports, then verify files exist and their content matches build_reports."""
|
||||
results_path = output_dir / "results"
|
||||
relevant, irrelevant = log_entries_tuple
|
||||
write_log_report(relevant, irrelevant, nb_files, results_path)
|
||||
# check if files were created
|
||||
all_file, n2_file, other_file = (
|
||||
(results_path / "all.log"),
|
||||
(results_path / "n2.log"),
|
||||
(results_path / "other.log"),
|
||||
)
|
||||
assert all_file.exists()
|
||||
assert n2_file.exists()
|
||||
assert other_file.exists()
|
||||
|
||||
# check content
|
||||
start_date, end_date = _get_period(relevant + irrelevant)
|
||||
reports = build_reports(relevant, irrelevant, start_date, end_date, nb_files)
|
||||
nb_errors = {
|
||||
"all": len(relevant + irrelevant),
|
||||
"n2": len(relevant),
|
||||
"other": len(irrelevant),
|
||||
}
|
||||
|
||||
assert reports["all"] == all_file.read_text(encoding="windows-1252")
|
||||
assert (
|
||||
all_file.read_text(encoding="windows-1252").find(
|
||||
f"Nombre total d'erreur(s)\t: {nb_errors['all']}"
|
||||
)
|
||||
!= -1
|
||||
)
|
||||
assert reports["n2"] == n2_file.read_text(encoding="windows-1252")
|
||||
assert (
|
||||
n2_file.read_text(encoding="windows-1252").find(
|
||||
f"Nombre total d'erreur(s)\t: {nb_errors['n2']}"
|
||||
)
|
||||
!= -1
|
||||
)
|
||||
assert reports["other"] == other_file.read_text(encoding="windows-1252")
|
||||
assert (
|
||||
other_file.read_text(encoding="windows-1252").find(
|
||||
f"Nombre total d'erreur(s)\t: {nb_errors['other']}"
|
||||
)
|
||||
!= -1
|
||||
)
|
||||
assert nb_errors[target_name_empty] == 0 # the target list must be empty
|
||||
|
||||
|
||||
def test_write_log_report_relevant(
|
||||
get_only_relevant_log_entries: tuple[list[LogEntry], list[LogEntry]],
|
||||
nb_files: int,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""Only-relevant input writes the three files with an empty "other" list."""
|
||||
_test_write_log_report(get_only_relevant_log_entries, nb_files, "other", tmp_path)
|
||||
|
||||
|
||||
def test_write_log_report_irrelevant(
|
||||
get_only_irrelevant_log_entries: tuple[list[LogEntry], list[LogEntry]],
|
||||
nb_files: int,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""Only-irrelevant input writes the three files with an empty "n2" list."""
|
||||
_test_write_log_report(get_only_irrelevant_log_entries, nb_files, "n2", tmp_path)
|
||||
|
||||
|
||||
def test_write_log_report_all_logs(
|
||||
get_all_log_entries: tuple[list[LogEntry], list[LogEntry]],
|
||||
nb_files: int,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""The full fixture writes three files whose content matches build_reports exactly."""
|
||||
results_path = tmp_path / "results"
|
||||
write_log_report(
|
||||
get_all_log_entries[0], get_all_log_entries[1], nb_files, results_path
|
||||
)
|
||||
|
||||
# check if files were created
|
||||
all_file, n2_file, other_file = (
|
||||
(results_path / "all.log"),
|
||||
(results_path / "n2.log"),
|
||||
(results_path / "other.log"),
|
||||
)
|
||||
assert all_file.exists()
|
||||
assert n2_file.exists()
|
||||
assert other_file.exists()
|
||||
|
||||
# check content
|
||||
start_date, end_date = _get_period(get_all_log_entries[0] + get_all_log_entries[1])
|
||||
reports = build_reports(
|
||||
get_all_log_entries[0], get_all_log_entries[1], start_date, end_date, nb_files
|
||||
)
|
||||
assert reports["all"] == all_file.read_text(encoding="windows-1252")
|
||||
assert reports["n2"] == n2_file.read_text(encoding="windows-1252")
|
||||
assert reports["other"] == other_file.read_text(encoding="windows-1252")
|
||||
|
||||
@@ -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,29 +308,52 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "logwatcher"
|
||||
version = "0.1.0"
|
||||
version = "0.0.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "pytest-cov", specifier = ">=7.1.0" }]
|
||||
requires-dist = [{ name = "typer", specifier = ">=0.27.2" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "mypy", specifier = ">=2.3.0" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
{ 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"
|
||||
@@ -459,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"
|
||||
@@ -484,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"
|
||||
@@ -538,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"
|
||||
|
||||
Reference in New Issue
Block a user