logdetective 2.0.1__py3-none-any.whl → 2.11.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.
- logdetective/extractors.py +134 -23
- logdetective/logdetective.py +39 -23
- logdetective/models.py +26 -0
- logdetective/prompts-summary-first.yml +0 -2
- logdetective/prompts.yml +0 -3
- logdetective/server/compressors.py +7 -10
- logdetective/server/config.py +3 -2
- logdetective/server/database/base.py +31 -26
- logdetective/server/database/models/__init__.py +2 -2
- logdetective/server/database/models/exceptions.py +4 -0
- logdetective/server/database/models/koji.py +47 -30
- logdetective/server/database/models/merge_request_jobs.py +205 -186
- logdetective/server/database/models/metrics.py +87 -61
- logdetective/server/emoji.py +57 -55
- logdetective/server/exceptions.py +4 -0
- logdetective/server/gitlab.py +18 -11
- logdetective/server/llm.py +19 -10
- logdetective/server/metric.py +18 -13
- logdetective/server/models.py +65 -48
- logdetective/server/plot.py +13 -11
- logdetective/server/server.py +52 -30
- logdetective/server/templates/base_response.html.j2 +59 -0
- logdetective/server/templates/gitlab_full_comment.md.j2 +58 -53
- logdetective/server/templates/gitlab_short_comment.md.j2 +52 -47
- logdetective/server/utils.py +15 -27
- logdetective/utils.py +115 -49
- {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/METADATA +95 -21
- logdetective-2.11.0.dist-info/RECORD +40 -0
- {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/WHEEL +1 -1
- logdetective-2.0.1.dist-info/RECORD +0 -39
- {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/entry_points.txt +0 -0
- {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info/licenses}/LICENSE +0 -0
logdetective/extractors.py
CHANGED
|
@@ -1,57 +1,168 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import logging
|
|
3
|
+
import subprocess as sp
|
|
3
4
|
from typing import Tuple
|
|
4
5
|
|
|
5
6
|
import drain3
|
|
6
7
|
from drain3.template_miner_config import TemplateMinerConfig
|
|
8
|
+
from pydantic import ValidationError
|
|
7
9
|
|
|
8
10
|
from logdetective.utils import get_chunks, filter_snippet_patterns
|
|
9
|
-
from logdetective.models import SkipSnippets
|
|
11
|
+
from logdetective.models import SkipSnippets, CSGrepOutput
|
|
10
12
|
|
|
11
13
|
LOG = logging.getLogger("logdetective")
|
|
12
14
|
|
|
13
15
|
|
|
14
|
-
class
|
|
15
|
-
"""
|
|
16
|
+
class Extractor:
|
|
17
|
+
"""Base extractor class."""
|
|
16
18
|
|
|
17
19
|
def __init__(
|
|
18
20
|
self,
|
|
19
21
|
verbose: bool = False,
|
|
20
|
-
context: bool = False,
|
|
21
|
-
max_clusters=8,
|
|
22
22
|
skip_snippets: SkipSnippets = SkipSnippets({}),
|
|
23
|
-
max_snippet_len: int = 2000
|
|
24
|
-
):
|
|
25
|
-
config = TemplateMinerConfig()
|
|
26
|
-
config.load(f"{os.path.dirname(__file__)}/drain3.ini")
|
|
27
|
-
config.profiling_enabled = verbose
|
|
28
|
-
config.drain_max_clusters = max_clusters
|
|
29
|
-
self.miner = drain3.TemplateMiner(config=config)
|
|
23
|
+
max_snippet_len: int = 2000,
|
|
24
|
+
):
|
|
30
25
|
self.verbose = verbose
|
|
31
|
-
self.context = context
|
|
32
26
|
self.skip_snippets = skip_snippets
|
|
33
27
|
self.max_snippet_len = max_snippet_len
|
|
34
28
|
|
|
29
|
+
if self.verbose:
|
|
30
|
+
LOG.setLevel(logging.DEBUG)
|
|
31
|
+
|
|
35
32
|
def __call__(self, log: str) -> list[Tuple[int, str]]:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def filter_snippet_patterns(
|
|
36
|
+
self, chunks: list[tuple[int, str]]
|
|
37
|
+
) -> list[tuple[int, str]]:
|
|
38
|
+
"""Keep only chunks that don't match any of the excluded patterns"""
|
|
40
39
|
chunks = [
|
|
41
40
|
(_, chunk)
|
|
42
41
|
for _, chunk in chunks
|
|
43
42
|
if not filter_snippet_patterns(chunk, self.skip_snippets)
|
|
44
43
|
]
|
|
45
|
-
|
|
44
|
+
return chunks
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DrainExtractor(Extractor):
|
|
48
|
+
"""A class that extracts information from logs using a template miner algorithm."""
|
|
49
|
+
|
|
50
|
+
_clusters: list
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
verbose: bool = False,
|
|
55
|
+
skip_snippets: SkipSnippets = SkipSnippets({}),
|
|
56
|
+
max_snippet_len: int = 2000,
|
|
57
|
+
max_clusters: int = 8,
|
|
58
|
+
):
|
|
59
|
+
super().__init__(verbose, skip_snippets, max_snippet_len)
|
|
60
|
+
config = TemplateMinerConfig()
|
|
61
|
+
config.load(f"{os.path.dirname(__file__)}/drain3.ini")
|
|
62
|
+
config.profiling_enabled = verbose
|
|
63
|
+
config.drain_max_clusters = max_clusters
|
|
64
|
+
self.miner = drain3.TemplateMiner(config=config)
|
|
65
|
+
|
|
66
|
+
def __call__(self, log: str) -> list[Tuple[int, str]]:
|
|
67
|
+
# Create chunks
|
|
68
|
+
chunks = list(get_chunks(log, self.max_snippet_len))
|
|
69
|
+
|
|
70
|
+
chunks = self.filter_snippet_patterns(chunks)
|
|
71
|
+
|
|
72
|
+
# First pass to create clusters
|
|
73
|
+
self._create_clusters(chunks=chunks)
|
|
74
|
+
|
|
75
|
+
# Second pass, only matching lines with clusters,
|
|
76
|
+
# to recover original text
|
|
77
|
+
snippets = self._extract_messages(chunks=chunks)
|
|
78
|
+
return snippets
|
|
79
|
+
|
|
80
|
+
def _create_clusters(self, chunks: list[tuple[int, str]]):
|
|
81
|
+
"""First pass to create clusters"""
|
|
46
82
|
for _, chunk in chunks:
|
|
47
83
|
processed_chunk = self.miner.add_log_message(chunk)
|
|
48
84
|
LOG.debug(processed_chunk)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
85
|
+
self._clusters = list(self.miner.drain.clusters)
|
|
86
|
+
|
|
87
|
+
def _extract_messages(self, chunks: list[tuple[int, str]]) -> list[tuple[int, str]]:
|
|
88
|
+
"""Second pass with drain using patterns from the first,
|
|
89
|
+
to extract matching lines and their numbers."""
|
|
90
|
+
out = []
|
|
91
|
+
|
|
52
92
|
for chunk_start, chunk in chunks:
|
|
53
93
|
cluster = self.miner.match(chunk, "always")
|
|
54
|
-
if cluster in
|
|
94
|
+
if cluster in self._clusters:
|
|
55
95
|
out.append((chunk_start, chunk))
|
|
56
|
-
|
|
96
|
+
self._clusters.remove(cluster)
|
|
57
97
|
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CSGrepExtractor(DrainExtractor):
|
|
101
|
+
"""Extract messages using csgrep
|
|
102
|
+
This extractor is only effective at retrieving messages from GCC
|
|
103
|
+
compiler and associated utilities, it is not capable of safely
|
|
104
|
+
extracting other messages from the logs. Therefore, it must only
|
|
105
|
+
be used together with the Drain based extractor."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
verbose: bool = False,
|
|
110
|
+
skip_snippets: SkipSnippets = SkipSnippets({}),
|
|
111
|
+
max_snippet_len: int = 2000,
|
|
112
|
+
max_clusters: int = 8,
|
|
113
|
+
):
|
|
114
|
+
super().__init__(verbose, skip_snippets, max_snippet_len, max_clusters)
|
|
115
|
+
|
|
116
|
+
def __call__(self, log: str) -> list[Tuple[int, str]]:
|
|
117
|
+
"""Extract error messages from log using csgrep"""
|
|
118
|
+
chunks = []
|
|
119
|
+
try:
|
|
120
|
+
# We are not running binary in check mode, since csgrep
|
|
121
|
+
# can produce many errors due to log file syntax
|
|
122
|
+
result = sp.run(
|
|
123
|
+
[
|
|
124
|
+
"csgrep",
|
|
125
|
+
"--event=error",
|
|
126
|
+
"--remove-duplicates",
|
|
127
|
+
"--mode=json",
|
|
128
|
+
"--quiet",
|
|
129
|
+
],
|
|
130
|
+
input=log,
|
|
131
|
+
shell=False,
|
|
132
|
+
check=False,
|
|
133
|
+
capture_output=True,
|
|
134
|
+
text=True,
|
|
135
|
+
timeout=1.0,
|
|
136
|
+
)
|
|
137
|
+
except sp.TimeoutExpired as ex:
|
|
138
|
+
LOG.exception("Exception encountered while parsing log with csgrep %s", ex)
|
|
139
|
+
raise ex
|
|
140
|
+
if result.returncode != 0:
|
|
141
|
+
# This can happen even if `csgrep` managed to extract useful info.
|
|
142
|
+
# Most commonly, when it encountered unexpected syntax in the log.
|
|
143
|
+
LOG.warning("csgrep call resulted in an error")
|
|
144
|
+
LOG.debug("csgrep error: `%s`", result.stderr)
|
|
145
|
+
if not result.stdout:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
# Parse JSON output from csgrep
|
|
149
|
+
try:
|
|
150
|
+
report = CSGrepOutput.model_validate_json(result.stdout)
|
|
151
|
+
except ValidationError as ex:
|
|
152
|
+
LOG.exception("Exception encountered while parsing csgrpe output %s", ex)
|
|
153
|
+
raise ex
|
|
154
|
+
for defect in report.defects:
|
|
155
|
+
# Single original error message can be split across multiple events
|
|
156
|
+
# before returning, we will turn them back into single string.
|
|
157
|
+
# We must also extract the original line number.
|
|
158
|
+
# Line number is NOT location of message in the log, but location of
|
|
159
|
+
# the issue in source, we can't really mix the two, so we'll set it to `0`.
|
|
160
|
+
|
|
161
|
+
chunks.append((0, "\n".join([event.message for event in defect.events])))
|
|
162
|
+
|
|
163
|
+
chunks = self.filter_snippet_patterns(chunks)
|
|
164
|
+
LOG.info("Total %d messages extracted with csgrep", len(chunks))
|
|
165
|
+
self._create_clusters(chunks=chunks)
|
|
166
|
+
snippets = self._extract_messages(chunks=chunks)
|
|
167
|
+
|
|
168
|
+
return snippets
|
logdetective/logdetective.py
CHANGED
|
@@ -15,8 +15,10 @@ from logdetective.utils import (
|
|
|
15
15
|
compute_certainty,
|
|
16
16
|
load_prompts,
|
|
17
17
|
load_skip_snippet_patterns,
|
|
18
|
+
check_csgrep,
|
|
19
|
+
mine_logs,
|
|
18
20
|
)
|
|
19
|
-
from logdetective.extractors import DrainExtractor
|
|
21
|
+
from logdetective.extractors import DrainExtractor, CSGrepExtractor
|
|
20
22
|
|
|
21
23
|
LOG = logging.getLogger("logdetective")
|
|
22
24
|
|
|
@@ -89,10 +91,13 @@ def setup_args():
|
|
|
89
91
|
default=f"{os.path.dirname(__file__)}/skip_snippets.yml",
|
|
90
92
|
help="Path to patterns for skipping snippets.",
|
|
91
93
|
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--csgrep", action="store_true", help="Use csgrep to process the log."
|
|
96
|
+
)
|
|
92
97
|
return parser.parse_args()
|
|
93
98
|
|
|
94
99
|
|
|
95
|
-
async def run(): # pylint: disable=too-many-statements,too-many-locals
|
|
100
|
+
async def run(): # pylint: disable=too-many-statements,too-many-locals,too-many-branches
|
|
96
101
|
"""Main execution function."""
|
|
97
102
|
args = setup_args()
|
|
98
103
|
|
|
@@ -134,13 +139,25 @@ async def run(): # pylint: disable=too-many-statements,too-many-locals
|
|
|
134
139
|
sys.exit(5)
|
|
135
140
|
|
|
136
141
|
# Log file summarizer initialization
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
+
extractors = []
|
|
143
|
+
extractors.append(
|
|
144
|
+
DrainExtractor(
|
|
145
|
+
args.verbose > 1,
|
|
146
|
+
max_clusters=args.n_clusters,
|
|
147
|
+
skip_snippets=skip_snippets,
|
|
148
|
+
)
|
|
142
149
|
)
|
|
143
150
|
|
|
151
|
+
if args.csgrep:
|
|
152
|
+
if not check_csgrep():
|
|
153
|
+
LOG.error(
|
|
154
|
+
"You have requested use of `csgrep` when it isn't available on your system."
|
|
155
|
+
)
|
|
156
|
+
sys.exit(6)
|
|
157
|
+
extractors.append(
|
|
158
|
+
CSGrepExtractor(args.verbose > 1, skip_snippets=skip_snippets)
|
|
159
|
+
)
|
|
160
|
+
|
|
144
161
|
LOG.info("Getting summary")
|
|
145
162
|
|
|
146
163
|
async with aiohttp.ClientSession() as http:
|
|
@@ -150,22 +167,13 @@ async def run(): # pylint: disable=too-many-statements,too-many-locals
|
|
|
150
167
|
# file does not exist
|
|
151
168
|
LOG.error(e)
|
|
152
169
|
sys.exit(4)
|
|
153
|
-
log_summary = extractor(log)
|
|
154
|
-
|
|
155
|
-
ratio = len(log_summary) / len(log.split("\n"))
|
|
156
|
-
|
|
157
|
-
LOG.info("Compression ratio: %s", ratio)
|
|
158
170
|
|
|
171
|
+
log_summary = mine_logs(log=log, extractors=extractors)
|
|
159
172
|
LOG.info("Analyzing the text")
|
|
160
173
|
|
|
161
174
|
log_summary = format_snippets(log_summary)
|
|
162
175
|
LOG.info("Log summary: \n %s", log_summary)
|
|
163
176
|
|
|
164
|
-
prompt = (
|
|
165
|
-
f"{prompts_configuration.default_system_prompt}\n"
|
|
166
|
-
f"{prompts_configuration.prompt_template}"
|
|
167
|
-
)
|
|
168
|
-
|
|
169
177
|
stream = True
|
|
170
178
|
if args.no_stream:
|
|
171
179
|
stream = False
|
|
@@ -173,30 +181,38 @@ async def run(): # pylint: disable=too-many-statements,too-many-locals
|
|
|
173
181
|
log_summary,
|
|
174
182
|
model,
|
|
175
183
|
stream,
|
|
176
|
-
|
|
184
|
+
prompt_templates=prompts_configuration,
|
|
177
185
|
temperature=args.temperature,
|
|
178
186
|
)
|
|
179
187
|
probs = []
|
|
180
188
|
print("Explanation:")
|
|
181
189
|
# We need to extract top token probability from the response
|
|
182
|
-
#
|
|
190
|
+
# CreateChatCompletionResponse structure of llama-cpp-python.
|
|
183
191
|
# `compute_certainty` function expects list of dictionaries with form
|
|
184
192
|
# { 'logprob': <float> } as expected from the OpenAI API.
|
|
185
193
|
|
|
186
194
|
if args.no_stream:
|
|
187
|
-
print(response["choices"][0]["
|
|
195
|
+
print(response["choices"][0]["message"]["content"])
|
|
188
196
|
probs = [
|
|
189
|
-
{"logprob": e} for e in response["choices"][0]["logprobs"]["
|
|
197
|
+
{"logprob": e["logprob"]} for e in response["choices"][0]["logprobs"]["content"]
|
|
190
198
|
]
|
|
191
199
|
|
|
192
200
|
else:
|
|
193
201
|
# Stream the output
|
|
194
202
|
for chunk in response:
|
|
203
|
+
# What might happen, is that first (or possibly any other) chunk may not contain
|
|
204
|
+
# fields choices[0].delta.content or choices[0].logprobs -> if so, we just skip it
|
|
205
|
+
if any([
|
|
206
|
+
'content' not in chunk["choices"][0]["delta"],
|
|
207
|
+
'logprobs' not in chunk["choices"][0]
|
|
208
|
+
]):
|
|
209
|
+
continue
|
|
210
|
+
|
|
195
211
|
if isinstance(chunk["choices"][0]["logprobs"], dict):
|
|
196
212
|
probs.append(
|
|
197
|
-
{"logprob": chunk["choices"][0]["logprobs"]["
|
|
213
|
+
{"logprob": chunk["choices"][0]["logprobs"]["content"][0]["logprob"]}
|
|
198
214
|
)
|
|
199
|
-
delta = chunk["choices"][0]["
|
|
215
|
+
delta = chunk["choices"][0]["delta"]["content"]
|
|
200
216
|
print(delta, end="", flush=True)
|
|
201
217
|
certainty = compute_certainty(probs)
|
|
202
218
|
|
logdetective/models.py
CHANGED
|
@@ -71,3 +71,29 @@ class SkipSnippets(BaseModel):
|
|
|
71
71
|
) from ex
|
|
72
72
|
|
|
73
73
|
return data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CSGrepEvent(BaseModel):
|
|
77
|
+
"""`csgrep` splits error and warning messages into individual events."""
|
|
78
|
+
|
|
79
|
+
file_name: str
|
|
80
|
+
line: int
|
|
81
|
+
event: str
|
|
82
|
+
message: str
|
|
83
|
+
verbosity_level: int
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CSGrepDefect(BaseModel):
|
|
87
|
+
"""Defects detected by `csgrep`"""
|
|
88
|
+
|
|
89
|
+
checker: str
|
|
90
|
+
language: str
|
|
91
|
+
tool: str
|
|
92
|
+
key_event_idx: int
|
|
93
|
+
events: list[CSGrepEvent]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CSGrepOutput(BaseModel):
|
|
97
|
+
"""Parsed output of `gsgrep`"""
|
|
98
|
+
|
|
99
|
+
defects: list[CSGrepDefect]
|
logdetective/prompts.yml
CHANGED
|
@@ -19,7 +19,6 @@ prompt_template: |
|
|
|
19
19
|
|
|
20
20
|
{}
|
|
21
21
|
|
|
22
|
-
Analysis:
|
|
23
22
|
|
|
24
23
|
snippet_prompt_template: |
|
|
25
24
|
Analyse following RPM build log snippet. Describe contents accurately, without speculation or suggestions for resolution
|
|
@@ -30,7 +29,6 @@ snippet_prompt_template: |
|
|
|
30
29
|
|
|
31
30
|
{}
|
|
32
31
|
|
|
33
|
-
Analysis:
|
|
34
32
|
|
|
35
33
|
prompt_template_staged: |
|
|
36
34
|
Given following log snippets, their explanation, and nothing else, explain what failure, if any, occurred during build of this package.
|
|
@@ -47,7 +45,6 @@ prompt_template_staged: |
|
|
|
47
45
|
|
|
48
46
|
{}
|
|
49
47
|
|
|
50
|
-
Analysis:
|
|
51
48
|
|
|
52
49
|
# System prompts
|
|
53
50
|
# System prompts are meant to serve as general guide for model behavior,
|
|
@@ -36,20 +36,17 @@ class TextCompressor:
|
|
|
36
36
|
zip_buffer.seek(0)
|
|
37
37
|
return zip_buffer.getvalue()
|
|
38
38
|
|
|
39
|
-
def unzip(self, zip_data:
|
|
39
|
+
def unzip(self, zip_data: bytes) -> Dict[str, str]:
|
|
40
40
|
"""
|
|
41
41
|
Uncompress data created by TextCompressor.zip().
|
|
42
42
|
|
|
43
43
|
Args:
|
|
44
|
-
zip_data: A zipped stream of bytes
|
|
44
|
+
zip_data: A zipped stream of bytes
|
|
45
45
|
|
|
46
46
|
Returns:
|
|
47
47
|
{file_name: str}: The decompressed content as a dict of file names and UTF-8 strings
|
|
48
48
|
"""
|
|
49
|
-
|
|
50
|
-
zip_buffer = io.BytesIO(zip_data)
|
|
51
|
-
else:
|
|
52
|
-
zip_buffer = zip_data
|
|
49
|
+
zip_buffer = io.BytesIO(zip_data)
|
|
53
50
|
|
|
54
51
|
content = {}
|
|
55
52
|
with zipfile.ZipFile(zip_buffer, "r") as zip_file:
|
|
@@ -95,12 +92,12 @@ class RemoteLogCompressor:
|
|
|
95
92
|
return self.zip_text(content_text)
|
|
96
93
|
|
|
97
94
|
@classmethod
|
|
98
|
-
def unzip(cls, zip_data:
|
|
95
|
+
def unzip(cls, zip_data: bytes) -> str:
|
|
99
96
|
"""
|
|
100
97
|
Uncompress the zipped content of the remote log.
|
|
101
98
|
|
|
102
99
|
Args:
|
|
103
|
-
zip_data: Compressed data as bytes
|
|
100
|
+
zip_data: Compressed data as bytes
|
|
104
101
|
|
|
105
102
|
Returns:
|
|
106
103
|
str: The decompressed log content
|
|
@@ -147,13 +144,13 @@ class LLMResponseCompressor:
|
|
|
147
144
|
|
|
148
145
|
@classmethod
|
|
149
146
|
def unzip(
|
|
150
|
-
cls, zip_data:
|
|
147
|
+
cls, zip_data: bytes
|
|
151
148
|
) -> Union[StagedResponse, Response]:
|
|
152
149
|
"""
|
|
153
150
|
Uncompress the zipped content of the LLM response.
|
|
154
151
|
|
|
155
152
|
Args:
|
|
156
|
-
zip_data: Compressed data as bytes
|
|
153
|
+
zip_data: Compressed data as bytes
|
|
157
154
|
|
|
158
155
|
Returns:
|
|
159
156
|
Union[StagedResponse, Response]: The decompressed (partial) response object,
|
logdetective/server/config.py
CHANGED
|
@@ -52,10 +52,11 @@ def get_log(config: Config):
|
|
|
52
52
|
return log
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
def get_openai_api_client(
|
|
55
|
+
def get_openai_api_client(inference_config: InferenceConfig):
|
|
56
56
|
"""Set up AsyncOpenAI client with default configuration."""
|
|
57
57
|
return AsyncOpenAI(
|
|
58
|
-
api_key=
|
|
58
|
+
api_key=inference_config.api_token, base_url=inference_config.url,
|
|
59
|
+
timeout=inference_config.llm_api_timeout
|
|
59
60
|
)
|
|
60
61
|
|
|
61
62
|
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
from os import getenv
|
|
2
|
-
from contextlib import
|
|
3
|
-
from sqlalchemy import
|
|
4
|
-
from sqlalchemy.
|
|
5
|
-
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
4
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
6
5
|
from logdetective import logger
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
def get_pg_url() -> str:
|
|
10
9
|
"""create postgresql connection string"""
|
|
11
10
|
return (
|
|
12
|
-
f"postgresql+
|
|
11
|
+
f"postgresql+asyncpg://{getenv('POSTGRESQL_USER')}"
|
|
13
12
|
f":{getenv('POSTGRESQL_PASSWORD')}@{getenv('POSTGRESQL_HOST', 'postgres')}"
|
|
14
13
|
f":{getenv('POSTGRESQL_PORT', '5432')}/{getenv('POSTGRESQL_DATABASE')}"
|
|
15
14
|
)
|
|
@@ -23,13 +22,16 @@ sqlalchemy_echo = getenv("SQLALCHEMY_ECHO", "False").lower() in (
|
|
|
23
22
|
"y",
|
|
24
23
|
"1",
|
|
25
24
|
)
|
|
26
|
-
engine =
|
|
27
|
-
SessionFactory =
|
|
28
|
-
|
|
25
|
+
engine = create_async_engine(get_pg_url(), echo=sqlalchemy_echo)
|
|
26
|
+
SessionFactory = async_sessionmaker(autoflush=True, bind=engine) # pylint: disable=invalid-name
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Base(DeclarativeBase):
|
|
30
|
+
"""Declarative base class for all ORM models."""
|
|
29
31
|
|
|
30
32
|
|
|
31
|
-
@
|
|
32
|
-
def transaction(commit: bool = False):
|
|
33
|
+
@asynccontextmanager
|
|
34
|
+
async def transaction(commit: bool = False):
|
|
33
35
|
"""
|
|
34
36
|
Context manager for 'framing' a db transaction.
|
|
35
37
|
|
|
@@ -39,27 +41,30 @@ def transaction(commit: bool = False):
|
|
|
39
41
|
"""
|
|
40
42
|
|
|
41
43
|
session = SessionFactory()
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
44
|
+
async with session:
|
|
45
|
+
try:
|
|
46
|
+
yield session
|
|
47
|
+
if commit:
|
|
48
|
+
await session.commit()
|
|
49
|
+
except Exception as ex:
|
|
50
|
+
logger.warning("Exception while working with database: %s", str(ex))
|
|
51
|
+
await session.rollback()
|
|
52
|
+
raise
|
|
53
|
+
finally:
|
|
54
|
+
await session.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def init():
|
|
55
58
|
"""Init db"""
|
|
56
|
-
|
|
59
|
+
async with engine.begin() as conn:
|
|
60
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
57
61
|
logger.debug("Database initialized")
|
|
58
62
|
|
|
59
63
|
|
|
60
|
-
def destroy():
|
|
64
|
+
async def destroy():
|
|
61
65
|
"""Destroy db"""
|
|
62
|
-
|
|
66
|
+
async with engine.begin() as conn:
|
|
67
|
+
await conn.run_sync(Base.metadata.drop_all)
|
|
63
68
|
logger.warning("Database cleaned")
|
|
64
69
|
|
|
65
70
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
from logdetective.server.database.base import Base
|
|
2
1
|
from logdetective.server.database.models.merge_request_jobs import (
|
|
3
2
|
Forge,
|
|
4
3
|
GitlabMergeRequestJobs,
|
|
@@ -18,8 +17,9 @@ from logdetective.server.database.models.exceptions import (
|
|
|
18
17
|
KojiTaskAnalysisTimeoutError,
|
|
19
18
|
)
|
|
20
19
|
|
|
20
|
+
# pylint: disable=undefined-all-variable
|
|
21
|
+
|
|
21
22
|
__all__ = [
|
|
22
|
-
Base.__name__,
|
|
23
23
|
GitlabMergeRequestJobs.__name__,
|
|
24
24
|
Comments.__name__,
|
|
25
25
|
Reactions.__name__,
|
|
@@ -11,3 +11,7 @@ class KojiTaskNotAnalyzedError(Exception):
|
|
|
11
11
|
|
|
12
12
|
class KojiTaskAnalysisTimeoutError(Exception):
|
|
13
13
|
"""Exception raised when a koji task analysis has timed out"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AnalyzeRequestMetricsNotFroundError(Exception):
|
|
17
|
+
"""Exception raised when AnalyzeRequestMetrics is not found"""
|