gool 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gool/__init__.py ADDED
File without changes
gool/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.0'
22
+ __version_tuple__ = version_tuple = (1, 0, 0)
23
+
24
+ __commit_id__ = commit_id = None
gool/log_clustering.py ADDED
@@ -0,0 +1,427 @@
1
+ #! /usr/bin/env python3
2
+ """
3
+ This script computes clusters of similar log lines from the provided log files.
4
+ It uses the drain3 library to extract templates from log lines.
5
+ It can filter log lines based on a regex pattern and display the clusters.
6
+ """
7
+
8
+ import dataclasses
9
+ import itertools
10
+ import logging
11
+ import pathlib
12
+ import re
13
+ import sys
14
+ import time
15
+ from collections import Counter, namedtuple
16
+ from collections.abc import Generator, Iterator
17
+ from enum import Enum
18
+ from math import ceil, log10
19
+ from typing import Annotated, Any, Union
20
+
21
+ import tyro
22
+ from drain3 import TemplateMiner # type: ignore
23
+ from drain3.masking import MaskingInstruction # type: ignore
24
+ from drain3.template_miner_config import TemplateMinerConfig # type: ignore
25
+ from rich.console import Console
26
+ from rich.logging import RichHandler
27
+ from rich.progress import BarColumn, Progress, TaskID, TaskProgressColumn, TextColumn
28
+ from rich.table import Table
29
+
30
+ try:
31
+ from gool._version import __version__
32
+ except ImportError:
33
+ __version__ = "unknown"
34
+
35
+
36
+ class ErrorCode(Enum):
37
+ """Error codes for the log clustering script."""
38
+
39
+ NO_LOG_FILES = -1
40
+ INVALID_TREE_DEPTH = -2
41
+ INVALID_REGEX = -3
42
+ FILE_NOT_FOUND = -4
43
+ IO_ERROR = -5
44
+
45
+
46
+ error_console = Console(file=sys.stderr, stderr=True)
47
+ console = Console()
48
+
49
+ logging.basicConfig(
50
+ format="%(module)s : %(message)s",
51
+ datefmt="%H:%M:%S.%f",
52
+ level=logging.INFO,
53
+ handlers=[RichHandler(console=error_console)],
54
+ )
55
+ logging.getLogger("drain3").setLevel(logging.WARNING)
56
+
57
+ HOME_CFG_FILE = pathlib.Path.home() / ".drain3.ini"
58
+ KB_FACTOR = 1000
59
+
60
+
61
+ @dataclasses.dataclass
62
+ class Arguments:
63
+ """
64
+ Use drain algorithm to cluster similar log lines
65
+ from the provided log files.
66
+
67
+ Arguments for the log clustering script :
68
+ """
69
+
70
+ # logs files paths to process
71
+ logfile_paths: tyro.conf.Positional[tuple[pathlib.Path, ...]]
72
+ # configuration file for the drain3 template miner.
73
+ cfg_file: Annotated[pathlib.Path, tyro.conf.arg(aliases=["-c"])] = HOME_CFG_FILE
74
+
75
+ # If set, filter input log lines which does not match the regex (re python module syntax).
76
+ # Example: '.*(\| Warning |\| Error ).*'
77
+ filter: Annotated[str, tyro.conf.arg(aliases=["-f"])] = ""
78
+ # If set, the clusters will be ordered lexicographically.
79
+ lex_order: Annotated[tyro.conf.FlagCreatePairsOff[bool], tyro.conf.arg(aliases=["-l"])] = False
80
+ # The clusters will be ordered by this total length. Where total length is the sum of all
81
+ # log lines lengths belonging to the cluster.
82
+ size_order: Annotated[tyro.conf.FlagCreatePairsOff[bool], tyro.conf.arg(aliases=["-z"])] = False
83
+ # Similarity threshold for the template miner to group lines together.
84
+ # A higher value will lead to create more clusters. Drain default value is 0.4.
85
+ similarity_threshold: Annotated[Union[float, None], tyro.conf.arg(aliases=["-s"])] = None
86
+ # depth of the tree to build the templates miner,
87
+ # a higher value will lead to create more clusters.
88
+ # The higher the value, the more tokens of the log lines
89
+ # will be considered to build the clusters. Increase this value
90
+ # to make clustering rely on distant tokens. Drain default value is 4.
91
+ tree_depth: Annotated[Union[int, None], tyro.conf.arg(aliases=["-d"], default=4)] = None
92
+ # If set, output clusters in plain text format without colors or table formatting,
93
+ # making it easy to process with bash tools like grep, awk, or cut.
94
+ raw: Annotated[tyro.conf.FlagCreatePairsOff[bool], tyro.conf.arg(aliases=["-r"])] = False
95
+ # return the version and exit
96
+ version: Annotated[tyro.conf.FlagCreatePairsOff[bool], tyro.conf.arg(aliases=["-v"])] = False
97
+
98
+ def __post_init__(self) -> None:
99
+ if self.version:
100
+ return
101
+ if self.logfile_paths is None or len(self.logfile_paths) == 0:
102
+ error_message = "No log files provided. Please specify at least one log file."
103
+ logging.critical(error_message)
104
+ sys.exit(ErrorCode.NO_LOG_FILES.value)
105
+ if self.tree_depth and self.tree_depth < 3:
106
+ error_message = f"The tree depth is set to {self.tree_depth}. Minimum value is 3."
107
+ logging.critical(error_message)
108
+ sys.exit(ErrorCode.INVALID_TREE_DEPTH.value)
109
+
110
+
111
+ def create_drain3_cfg(args: Arguments) -> Any:
112
+ """
113
+ Create a default configuration for the drain3 template miner.
114
+
115
+ Returns:
116
+ TemplateMinerConfig: A configuration object for the drain3 template miner.
117
+ """
118
+ drain3_cfg = TemplateMinerConfig()
119
+ if args.cfg_file.exists():
120
+ logging.info("Loading configuration from %s", args.cfg_file)
121
+ drain3_cfg.load(args.cfg_file)
122
+ else:
123
+ logging.info(
124
+ "Configuration file %s not found. Using default configuration to mask IP, time, and hex values.",
125
+ args.cfg_file,
126
+ )
127
+ mask_time = MaskingInstruction(r"(\d{2}:\d{2}:\d{2}(.\d+|))", "TIME")
128
+ mask_ip = MaskingInstruction(
129
+ r"((?<=[^A-Za-z0-9])|^)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})((?=[^A-Za-z0-9])|$)",
130
+ "IP",
131
+ )
132
+ mask_hex = MaskingInstruction(
133
+ r"((?<=[^A-Za-z0-9])|^)(0[xX][0-9a-fA-F]+)((?=[^A-Za-z0-9])|$)",
134
+ "HEX",
135
+ )
136
+ # Add masking instructions to the configuration
137
+ drain3_cfg.masking_instructions += [mask_time, mask_ip, mask_hex]
138
+
139
+ if args.similarity_threshold:
140
+ drain3_cfg.drain_sim_th = args.similarity_threshold
141
+ if args.tree_depth:
142
+ drain3_cfg.drain_depth = args.tree_depth
143
+ return drain3_cfg
144
+
145
+
146
+ def estimate_lines(path: pathlib.Path, nb_sample_lines: int = 1000) -> int:
147
+ """
148
+ Estimate total lines based on file size and sample average.
149
+ Args:
150
+ path (pathlib.Path): Path to the log file.
151
+ nb_sample_lines (int): Number of lines to sample for average calculation.
152
+ """
153
+ file_size = path.stat().st_size
154
+ if file_size == 0:
155
+ return 0
156
+ # Sample first 'sample_lines' to get avg bytes per line
157
+ avg_bytes_per_line = 0
158
+ with open(path, "rb") as f: # Use binary for accurate byte counting
159
+ for i, line in enumerate(f):
160
+ if i >= nb_sample_lines:
161
+ break
162
+ avg_bytes_per_line += len(line)
163
+ if nb_sample_lines > 0:
164
+ avg_bytes_per_line //= nb_sample_lines
165
+ # Estimate total lines
166
+ estimated = int(file_size / avg_bytes_per_line) if avg_bytes_per_line > 0 else 0
167
+ return max(estimated, 1)
168
+
169
+
170
+ def create_file_line_generators(
171
+ logfile_paths: tuple[pathlib.Path, ...],
172
+ progress: Progress,
173
+ ) -> itertools.chain:
174
+ """
175
+ Create progress tasks for all files and return a chained generator yielding lines from all files.
176
+
177
+ Args:
178
+ logfile_paths (tuple[pathlib.Path, ...]): Paths to the log files.
179
+ progress (Progress): The progress instance to track file processing.
180
+
181
+ Returns:
182
+ Generator: A single generator yielding lines from all files.
183
+ """
184
+ generators = []
185
+ for logfile_path in logfile_paths:
186
+ sample_nb_lines = 20000
187
+ number_of_lines = estimate_lines(logfile_path, sample_nb_lines)
188
+ task_id = progress.add_task(f"{pathlib.Path(logfile_path).name}", total=number_of_lines)
189
+
190
+ def line_generator(path: pathlib.Path, tid: TaskID, nb_lines: int) -> Generator[str, None, None]:
191
+ """Generator that yields lines from a file and updates progress."""
192
+ with open(path, encoding="utf-8", errors="surrogateescape") as f:
193
+ for line in f:
194
+ progress.update(tid, advance=1)
195
+ yield line
196
+ progress.update(tid, completed=nb_lines)
197
+ progress.stop_task(tid)
198
+
199
+ generators.append(line_generator(logfile_path, task_id, number_of_lines))
200
+
201
+ # Chain all generators into one
202
+ return itertools.chain(*generators)
203
+
204
+
205
+ def add_log_lines_to_miner(
206
+ template_miner: Any,
207
+ line_generator: Iterator[str],
208
+ regex: Union[re.Pattern[str], None],
209
+ ) -> tuple[int, Counter]:
210
+ """
211
+ Add log lines from a generator to the template miner.
212
+
213
+ Args:
214
+ template_miner (TemplateMiner): The template miner instance.
215
+ line_generator (Generator): A generator yielding log lines.
216
+ regex (re.Pattern | None): Regex pattern to filter log lines.
217
+
218
+ Returns:
219
+ tuple[int, Counter]: The number of lines added and cluster sizes counter.
220
+ """
221
+ start_time = time.perf_counter()
222
+ total_nb_lines = 0
223
+ cluster_char_sizes: Counter = Counter()
224
+
225
+ for line in line_generator:
226
+ if not line:
227
+ continue
228
+ if regex and not regex.match(line):
229
+ continue
230
+ total_nb_lines += 1
231
+ result = template_miner.add_log_message(line)
232
+ cluster_char_sizes[result["cluster_id"]] += len(line)
233
+ end_time = time.perf_counter()
234
+ elapsed_time = end_time - start_time
235
+ logging.info(
236
+ "Processed %d lines in %.2f seconds (%.2f lines/second).",
237
+ total_nb_lines,
238
+ elapsed_time,
239
+ total_nb_lines / elapsed_time if elapsed_time > 0 else 0,
240
+ )
241
+ return total_nb_lines, cluster_char_sizes
242
+
243
+
244
+ def surrogate_non_printable(s: str) -> str:
245
+ """
246
+ Surrogate non-printable characters from a string.
247
+
248
+ Args:
249
+ s (str): The input string.
250
+ Returns:
251
+ str: The cleaned string.
252
+ """
253
+ return s.encode("utf-8", errors="surrogateescape").decode("utf-8")
254
+
255
+
256
+ def compute_margin_for_display(max_number: int) -> int:
257
+ """
258
+ Compute the margin for displaying numbers.
259
+
260
+ Args:
261
+ max_number (int): The maximum number to display.
262
+ Returns:
263
+ int: The computed margin.
264
+ """
265
+ b10 = log10(max_number)
266
+ margin = ceil(log10(max_number)) if b10 != int(b10) else int(b10 + 1)
267
+ return margin
268
+
269
+
270
+ def display_clusters(
271
+ template_miner: Any,
272
+ cluster_char_sizes: Counter,
273
+ order_by: str = "count",
274
+ raw: bool = False,
275
+ ) -> int:
276
+ """
277
+ Display all clusters in a table with 3 columns: Count - Char Size (KB) - Template.
278
+
279
+ Args:
280
+ template_miner (TemplateMiner): the template miner which has been filled with log lines.
281
+ cluster_char_sizes (Counter): a counter of the total sizes of each cluster.
282
+ order_by (str): How to order clusters: "count", "size", or "template". Defaults to "count".
283
+
284
+ Returns:
285
+ int: The total number of lines in all clusters.
286
+ """
287
+ ClusterResult = namedtuple("ClusterResult", ["count", "char_size", "template"])
288
+ clusters_data = [
289
+ ClusterResult(
290
+ count=cluster.size,
291
+ char_size=cluster_char_sizes[cluster.cluster_id],
292
+ template=cluster.get_template(),
293
+ )
294
+ for cluster in template_miner.drain.clusters
295
+ ]
296
+
297
+ if not clusters_data:
298
+ return 0
299
+
300
+ # Sort based on order_by parameter
301
+ if order_by == "size":
302
+ clusters_data.sort(key=lambda x: x.char_size, reverse=True)
303
+ elif order_by == "template":
304
+ clusters_data.sort(key=lambda x: x.template)
305
+ else: # default to "count"
306
+ clusters_data.sort(key=lambda x: x.count, reverse=True)
307
+
308
+ # Add rows to the table or print plain text
309
+ total_nb_lines_clusters = 0
310
+ if raw:
311
+ # Plain text output for bash processing
312
+ count_margin = compute_margin_for_display(max(cluster.count for cluster in clusters_data))
313
+ size_margin = compute_margin_for_display(max(cluster.char_size for cluster in clusters_data) // KB_FACTOR)
314
+ for cluster in clusters_data:
315
+ pattern = surrogate_non_printable(cluster.template)
316
+ count_str = f"{cluster.count}"
317
+ size_kb = cluster.char_size // KB_FACTOR
318
+ size_str = f"{size_kb}"
319
+ console.print(
320
+ f"{count_str:>{count_margin}} - {size_str:>{size_margin}} - {pattern}",
321
+ soft_wrap=True,
322
+ markup=False,
323
+ )
324
+ total_nb_lines_clusters += cluster.count
325
+ else:
326
+ # Create a Rich table
327
+ table = Table(title="Log Clusters", highlight=True)
328
+ table.add_column("Count", justify="right", style="cyan", no_wrap=True)
329
+ table.add_column("Char Size (KB)", justify="right", style="magenta", no_wrap=True)
330
+ table.add_column("Template", justify="left")
331
+
332
+ for cluster in clusters_data:
333
+ pattern = surrogate_non_printable(cluster.template)
334
+ count_str = f"{cluster.count:,}".replace(",", " ")
335
+ size_kb = cluster.char_size // KB_FACTOR
336
+ size_str = f"{size_kb:,}".replace(",", " ")
337
+ table.add_row(count_str, size_str, pattern)
338
+ total_nb_lines_clusters += cluster.count
339
+
340
+ # Print the table
341
+ console.print(table, markup=False)
342
+
343
+ return total_nb_lines_clusters
344
+
345
+
346
+ def main(args: Arguments) -> int:
347
+ """
348
+ Extract the cluster templates from the provided log files.
349
+
350
+ Args:
351
+ args (Arguments): paths and options for the log extractor
352
+
353
+ Returns:
354
+ int: 0 if everything went well
355
+ """
356
+ drain3_cfg = create_drain3_cfg(args)
357
+ template_miner = TemplateMiner(config=drain3_cfg)
358
+ regex = None
359
+ try:
360
+ regex = re.compile(args.filter) if args.filter else None
361
+ except re.error as e:
362
+ logging.critical("Invalid regex pattern: %s. Error: %s", args.filter, e)
363
+ sys.exit(ErrorCode.INVALID_REGEX.value)
364
+ total_nb_lines, cluster_char_sizes = 0, None
365
+ try:
366
+ with Progress(
367
+ TextColumn("[progress.description]{task.description}"),
368
+ BarColumn(),
369
+ TaskProgressColumn(),
370
+ console=error_console,
371
+ ) as progress:
372
+ line_generator = create_file_line_generators(args.logfile_paths, progress)
373
+ total_nb_lines, cluster_char_sizes = add_log_lines_to_miner(template_miner, line_generator, regex)
374
+ except FileNotFoundError as e:
375
+ logging.critical("File not found: %s", e.filename)
376
+ sys.exit(ErrorCode.FILE_NOT_FOUND.value)
377
+ except OSError as e:
378
+ logging.critical("I/O error(%s): %s", e.errno, e.strerror)
379
+ sys.exit(ErrorCode.IO_ERROR.value)
380
+
381
+ def display_results() -> int:
382
+ order = "count"
383
+ if args.lex_order:
384
+ order = "template"
385
+ elif args.size_order:
386
+ order = "size"
387
+ total_nb_lines_clusters = display_clusters(template_miner, cluster_char_sizes, order_by=order, raw=args.raw)
388
+ return total_nb_lines_clusters
389
+
390
+ def sanity_check(total_nb_lines_clusters: int) -> int:
391
+ """Check if the total number of lines in clusters matches the processed lines."""
392
+ result = 0
393
+ if total_nb_lines_clusters != total_nb_lines:
394
+ logging.error(
395
+ "The number of lines in the clusters (%d) does "
396
+ "not match the total number of lines processed (%d)."
397
+ "Maybe you should increase [DRAIN]/max_clusters parameter.",
398
+ total_nb_lines_clusters,
399
+ total_nb_lines,
400
+ )
401
+ result = 1
402
+ return result
403
+
404
+ total_nb_lines_clusters = display_results()
405
+ return sanity_check(total_nb_lines_clusters)
406
+
407
+
408
+ def main_cli() -> int:
409
+ """
410
+ Main entry point for the CLI.
411
+
412
+ Returns:
413
+ int: Exit code.
414
+ """
415
+ try:
416
+ cfg = tyro.cli(Arguments)
417
+ if cfg.version:
418
+ print(__version__)
419
+ return 0
420
+ return main(cfg)
421
+ except KeyboardInterrupt:
422
+ error_console.print("\n[red]Process interrupted by user[/red]")
423
+ return ErrorCode.IO_ERROR.value
424
+
425
+
426
+ if __name__ == "__main__":
427
+ main_cli()
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: gool
3
+ Version: 1.0.0
4
+ Summary: cli logs clustering
5
+ Project-URL: Homepage, https://github.com/leCapi/gool
6
+ Project-URL: Repository, https://github.com/leCapi/gool
7
+ Project-URL: Documentation, https://github.com/leCapi/gool
8
+ Author: Olivier GODARD
9
+ License-File: LICENSE
10
+ Keywords: python
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: <4.0,>=3.10
21
+ Requires-Dist: drain3~=0.9
22
+ Requires-Dist: rich~=14.0
23
+ Requires-Dist: tyro<=1.0.8,>=0.9
24
+ Description-Content-Type: text/markdown
25
+
26
+ # gool, repo usage
27
+
28
+ The gool repository uses uv and git. Version is taken from git tag. The repo provide setup for VSCode.
29
+
30
+ ## Setup
31
+
32
+ Below the more useful commands.
33
+
34
+ setup the uv virtual environment and install pre-commit hooks :
35
+
36
+ ```bash
37
+ make install
38
+ ```
39
+
40
+ Generate and launch the documentation server :
41
+
42
+ ```bash
43
+ make docs
44
+ ```
45
+
46
+ All commands:
47
+
48
+ ```bash
49
+ make help
50
+ ```
51
+
52
+ ---
53
+
54
+ Repository initiated with [fpgmaas/cookiecutter-uv](https://github.com/fpgmaas/cookiecutter-uv).
@@ -0,0 +1,8 @@
1
+ gool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ gool/_version.py,sha256=JAAyU3al4wmBf3BF-Umm1J_GrCIT-yS_yWEGHqKRVHc,520
3
+ gool/log_clustering.py,sha256=dhWlwaItagXJ_eTH-ivtTiUxMI4hcud-_8I0GTrkfYM,15449
4
+ gool-1.0.0.dist-info/METADATA,sha256=a4hgzEyAxQpfln6BHLvGV7ygIA1iLeh_CB5oj1OzvwI,1396
5
+ gool-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
6
+ gool-1.0.0.dist-info/entry_points.txt,sha256=0mQBhFOgYtj1BakyfMIkdTAaNx4fURkLuBYxKBIADW0,54
7
+ gool-1.0.0.dist-info/licenses/LICENSE,sha256=tsxRB5OyrYVPFO3godPt7uqDdLPPKtKr_sTGchF3UZ8,1071
8
+ gool-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gool = gool.log_clustering:main_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Olivier GODARD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.