logdetective 2.4.0__tar.gz → 2.5.0__tar.gz

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.
Files changed (41) hide show
  1. {logdetective-2.4.0 → logdetective-2.5.0}/PKG-INFO +4 -3
  2. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/config.py +3 -2
  3. logdetective-2.5.0/logdetective/server/database/base.py +68 -0
  4. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/database/models/koji.py +29 -22
  5. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/database/models/merge_request_jobs.py +163 -164
  6. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/database/models/metrics.py +61 -46
  7. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/emoji.py +7 -7
  8. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/gitlab.py +6 -6
  9. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/metric.py +9 -9
  10. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/models.py +2 -0
  11. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/plot.py +12 -10
  12. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/server.py +19 -11
  13. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/templates/gitlab_full_comment.md.j2 +8 -6
  14. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/utils.py +7 -0
  15. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/utils.py +36 -29
  16. {logdetective-2.4.0 → logdetective-2.5.0}/pyproject.toml +25 -6
  17. logdetective-2.4.0/logdetective/server/database/base.py +0 -66
  18. {logdetective-2.4.0 → logdetective-2.5.0}/LICENSE +0 -0
  19. {logdetective-2.4.0 → logdetective-2.5.0}/README.md +0 -0
  20. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/__init__.py +0 -0
  21. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/constants.py +0 -0
  22. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/drain3.ini +0 -0
  23. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/extractors.py +0 -0
  24. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/logdetective.py +0 -0
  25. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/models.py +0 -0
  26. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/prompts-summary-first.yml +0 -0
  27. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/prompts-summary-only.yml +0 -0
  28. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/prompts.yml +0 -0
  29. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/remote_log.py +0 -0
  30. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/__init__.py +0 -0
  31. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/compressors.py +0 -0
  32. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/database/__init__.py +0 -0
  33. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/database/models/__init__.py +0 -0
  34. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/database/models/exceptions.py +0 -0
  35. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/exceptions.py +0 -0
  36. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/koji.py +0 -0
  37. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/llm.py +0 -0
  38. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/templates/base_response.html.j2 +0 -0
  39. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/server/templates/gitlab_short_comment.md.j2 +0 -0
  40. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective/skip_snippets.yml +0 -0
  41. {logdetective-2.4.0 → logdetective-2.5.0}/logdetective.1.asciidoc +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logdetective
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Log using LLM AI to search for build/test failures and provide ideas for fixing these.
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -27,6 +27,8 @@ Requires-Dist: aiohttp (>=3.7.4,<4.0.0)
27
27
  Requires-Dist: aiolimiter (>=1.0.0,<2.0.0) ; extra == "server"
28
28
  Requires-Dist: aioresponses (>=0.7.8,<0.8.0) ; extra == "testing"
29
29
  Requires-Dist: alembic (>=1.13.3,<2.0.0) ; extra == "server" or extra == "server-testing"
30
+ Requires-Dist: asciidoc[testing] (>=10.2.1,<11.0.0) ; extra == "testing"
31
+ Requires-Dist: asyncpg (>=0.30.0,<0.31.0) ; extra == "server" or extra == "server-testing"
30
32
  Requires-Dist: backoff (==2.2.1) ; extra == "server" or extra == "server-testing"
31
33
  Requires-Dist: drain3 (>=0.9.11,<0.10.0)
32
34
  Requires-Dist: fastapi (>=0.111.1,<1.0.0) ; extra == "server" or extra == "server-testing"
@@ -37,11 +39,10 @@ Requires-Dist: llama-cpp-python (>0.2.56,!=0.2.86,<1.0.0)
37
39
  Requires-Dist: matplotlib (>=3.8.4,<4.0.0) ; extra == "server" or extra == "server-testing"
38
40
  Requires-Dist: numpy (>=1.26.0)
39
41
  Requires-Dist: openai (>=1.82.1,<2.0.0) ; extra == "server" or extra == "server-testing"
40
- Requires-Dist: psycopg2 (>=2.9.9,<3.0.0) ; extra == "server"
41
- Requires-Dist: psycopg2-binary (>=2.9.9,<3.0.0) ; extra == "server-testing"
42
42
  Requires-Dist: pydantic (>=2.8.2,<3.0.0)
43
43
  Requires-Dist: pytest (>=8.4.1,<9.0.0) ; extra == "testing"
44
44
  Requires-Dist: pytest-asyncio (>=1.1.0,<2.0.0) ; extra == "testing"
45
+ Requires-Dist: pytest-cov[testing] (>=7.0.0,<8.0.0) ; extra == "testing"
45
46
  Requires-Dist: pytest-mock (>=3.14.1,<4.0.0) ; extra == "server-testing"
46
47
  Requires-Dist: python-gitlab (>=4.4.0)
47
48
  Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
@@ -52,10 +52,11 @@ def get_log(config: Config):
52
52
  return log
53
53
 
54
54
 
55
- def get_openai_api_client(ineference_config: InferenceConfig):
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=ineference_config.api_token, base_url=ineference_config.url
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
 
@@ -0,0 +1,68 @@
1
+ from os import getenv
2
+ from contextlib import asynccontextmanager
3
+ from sqlalchemy.orm import declarative_base
4
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
5
+ from logdetective import logger
6
+
7
+
8
+ def get_pg_url() -> str:
9
+ """create postgresql connection string"""
10
+ return (
11
+ f"postgresql+asyncpg://{getenv('POSTGRESQL_USER')}"
12
+ f":{getenv('POSTGRESQL_PASSWORD')}@{getenv('POSTGRESQL_HOST', 'postgres')}"
13
+ f":{getenv('POSTGRESQL_PORT', '5432')}/{getenv('POSTGRESQL_DATABASE')}"
14
+ )
15
+
16
+
17
+ # To log SQL statements, set SQLALCHEMY_ECHO env. var. to True|T|Yes|Y|1
18
+ sqlalchemy_echo = getenv("SQLALCHEMY_ECHO", "False").lower() in (
19
+ "true",
20
+ "t",
21
+ "yes",
22
+ "y",
23
+ "1",
24
+ )
25
+ engine = create_async_engine(get_pg_url(), echo=sqlalchemy_echo)
26
+ SessionFactory = async_sessionmaker(autoflush=True, bind=engine) # pylint: disable=invalid-name
27
+ Base = declarative_base()
28
+
29
+
30
+ @asynccontextmanager
31
+ async def transaction(commit: bool = False):
32
+ """
33
+ Context manager for 'framing' a db transaction.
34
+
35
+ Args:
36
+ commit: Whether to call `Session.commit()` upon exiting the context. Should be set to True
37
+ if any changes are made within the context. Defaults to False.
38
+ """
39
+
40
+ session = SessionFactory()
41
+ async with session:
42
+ try:
43
+ yield session
44
+ if commit:
45
+ await session.commit()
46
+ except Exception as ex:
47
+ logger.warning("Exception while working with database: %s", str(ex))
48
+ await session.rollback()
49
+ raise
50
+ finally:
51
+ await session.close()
52
+
53
+
54
+ async def init():
55
+ """Init db"""
56
+ async with engine.begin() as conn:
57
+ await conn.run_sync(Base.metadata.create_all)
58
+ logger.debug("Database initialized")
59
+
60
+
61
+ async def destroy():
62
+ """Destroy db"""
63
+ async with engine.begin() as conn:
64
+ await conn.run_sync(Base.metadata.drop_all)
65
+ logger.warning("Database cleaned")
66
+
67
+
68
+ DB_MAX_RETRIES = 3 # How many times retry a db operation
@@ -1,5 +1,5 @@
1
1
  from datetime import datetime, timedelta, timezone
2
- from sqlalchemy import Column, BigInteger, DateTime, ForeignKey, Integer, String
2
+ from sqlalchemy import Column, BigInteger, DateTime, ForeignKey, Integer, String, select
3
3
  from sqlalchemy.orm import relationship
4
4
  from sqlalchemy.exc import OperationalError
5
5
  import backoff
@@ -26,7 +26,7 @@ class KojiTaskAnalysis(Base):
26
26
  task_id = Column(BigInteger, nullable=False, index=True, unique=True)
27
27
  log_file_name = Column(String(255), nullable=False, index=True)
28
28
  request_received_at = Column(
29
- DateTime,
29
+ DateTime(timezone=True),
30
30
  nullable=False,
31
31
  index=True,
32
32
  default=datetime.now(timezone.utc),
@@ -43,20 +43,22 @@ class KojiTaskAnalysis(Base):
43
43
 
44
44
  @classmethod
45
45
  @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
46
- def create_or_restart(cls, koji_instance: str, task_id: int, log_file_name: str):
46
+ async def create_or_restart(
47
+ cls, koji_instance: str, task_id: int, log_file_name: str
48
+ ):
47
49
  """Create a new koji task analysis"""
48
- with transaction(commit=True) as session:
50
+ query = select(cls).filter(
51
+ cls.koji_instance == koji_instance, cls.task_id == task_id
52
+ )
53
+ async with transaction(commit=True) as session:
49
54
  # Check if the task analysis already exists
50
- koji_task_analysis = (
51
- session.query(cls)
52
- .filter_by(koji_instance=koji_instance, task_id=task_id)
53
- .first()
54
- )
55
+ query_result = await session.execute(query)
56
+ koji_task_analysis = query_result.first()
55
57
  if koji_task_analysis:
56
58
  # If it does, update the request_received_at timestamp
57
59
  koji_task_analysis.request_received_at = datetime.now(timezone.utc)
58
60
  session.add(koji_task_analysis)
59
- session.flush()
61
+ await session.flush()
60
62
  return
61
63
 
62
64
  # If it doesn't, create a new one
@@ -65,14 +67,19 @@ class KojiTaskAnalysis(Base):
65
67
  koji_task_analysis.task_id = task_id
66
68
  koji_task_analysis.log_file_name = log_file_name
67
69
  session.add(koji_task_analysis)
68
- session.flush()
70
+ await session.flush()
69
71
 
70
72
  @classmethod
71
73
  @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
72
- def add_response(cls, task_id: int, metric_id: int):
74
+ async def add_response(cls, task_id: int, metric_id: int):
73
75
  """Add a response to a koji task analysis"""
74
- with transaction(commit=True) as session:
75
- koji_task_analysis = session.query(cls).filter_by(task_id=task_id).first()
76
+ query = select(cls).filter(cls.task_id == task_id)
77
+ metrics_query = select(AnalyzeRequestMetrics).filter(
78
+ AnalyzeRequestMetrics.id == metric_id
79
+ )
80
+ async with transaction(commit=True) as session:
81
+ query_result = await session.execute(query)
82
+ koji_task_analysis = query_result.scalars().first()
76
83
  # Ensure that the task analysis doesn't already have a response
77
84
  if koji_task_analysis.response:
78
85
  # This is probably due to an analysis that took so long that
@@ -81,20 +88,20 @@ class KojiTaskAnalysis(Base):
81
88
  # returned to the consumer, so we'll just drop this extra one
82
89
  # on the floor and keep the one saved in the database.
83
90
  return
84
-
85
- metric = (
86
- session.query(AnalyzeRequestMetrics).filter_by(id=metric_id).first()
87
- )
91
+ metrics_query_result = await session.execute(metrics_query)
92
+ metric = metrics_query_result.scalars().first()
88
93
  koji_task_analysis.response = metric
89
94
  session.add(koji_task_analysis)
90
- session.flush()
95
+ await session.flush()
91
96
 
92
97
  @classmethod
93
98
  @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
94
- def get_response_by_task_id(cls, task_id: int) -> KojiStagedResponse:
99
+ async def get_response_by_task_id(cls, task_id: int) -> KojiStagedResponse:
95
100
  """Get a koji task analysis by task id"""
96
- with transaction(commit=False) as session:
97
- koji_task_analysis = session.query(cls).filter_by(task_id=task_id).first()
101
+ query = select(cls).filter(cls.task_id == task_id)
102
+ async with transaction(commit=False) as session:
103
+ query_result = await session.execute(query)
104
+ koji_task_analysis = query_result.scalars().first()
98
105
  if not koji_task_analysis:
99
106
  raise KojiTaskNotFoundError(f"Task {task_id} not yet analyzed")
100
107