PyObservability 1.0.1__py3-none-any.whl → 1.2.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.
@@ -32,7 +32,7 @@ def _cli() -> None:
32
32
  )
33
33
  args = [arg.lower() for arg in sys.argv[1:]]
34
34
  try:
35
- assert len(args) > 1
35
+ assert len(args) >= 1
36
36
  except (IndexError, AttributeError, AssertionError):
37
37
  print(f"Cannot proceed without a valid arbitrary command. Please choose from {choices}")
38
38
  exit(1)
@@ -2,10 +2,23 @@ from enum import StrEnum
2
2
 
3
3
 
4
4
  class APIEndpoints(StrEnum):
5
+ """API endpoints for all the routes.
6
+
7
+ >>> APIEndpoints
8
+
9
+ """
10
+
11
+ health = "/health"
5
12
  root = "/"
6
13
  ws = "/ws"
7
14
 
8
15
 
9
16
  class Log(StrEnum):
17
+ """Log output options.
18
+
19
+ >>> Log
20
+
21
+ """
22
+
10
23
  file = "file"
11
24
  stdout = "stdout"
@@ -13,6 +13,16 @@ from pyobservability.config import enums
13
13
 
14
14
 
15
15
  def detailed_log_config(filename: str | None = None, debug: bool = False) -> Dict[str, Any]:
16
+ """Generate a detailed logging configuration.
17
+
18
+ Args:
19
+ filename: Optional log file name. If None, logs to stdout.
20
+ debug: If True, sets log level to DEBUG, else INFO.
21
+
22
+ Returns:
23
+ Dict[str, Any]:
24
+ Returns the logging configuration dictionary.
25
+ """
16
26
  if filename:
17
27
  log_handler = {
18
28
  "class": "logging.FileHandler",
@@ -62,11 +72,24 @@ class PydanticEnvConfig(BaseSettings):
62
72
  dotenv_settings,
63
73
  file_secret_settings,
64
74
  ):
65
- """Order: dotenv, env, init, secrets files."""
66
- return dotenv_settings, env_settings, init_settings, file_secret_settings
75
+ """Customize the order of settings sources."""
76
+ # Precedence (last wins):
77
+ # env < dotenv < file secrets < init
78
+ return (
79
+ env_settings,
80
+ dotenv_settings,
81
+ file_secret_settings,
82
+ init_settings,
83
+ )
67
84
 
68
85
 
69
86
  class MonitorTarget(BaseModel):
87
+ """Model representing a monitoring target.
88
+
89
+ >>> MonitorTarget
90
+
91
+ """
92
+
70
93
  name: str
71
94
  base_url: HttpUrl
72
95
  apikey: str
@@ -100,17 +123,20 @@ class EnvConfig(PydanticEnvConfig):
100
123
  interval: PositiveInt = Field(3, validation_alias=alias_choices("INTERVAL"))
101
124
 
102
125
  log: enums.Log | None = None
126
+ logs_path: str = "logs"
103
127
  debug: bool = False
104
128
  log_config: Dict[str, Any] | FilePath | None = None
105
129
 
106
130
  username: str | None = Field(None, validation_alias=alias_choices("USERNAME"))
107
131
  password: str | None = Field(None, validation_alias=alias_choices("PASSWORD"))
132
+ timeout: PositiveInt = Field(300, validation_alias=alias_choices("TIMEOUT"))
108
133
 
109
134
  class Config:
110
135
  """Environment variables configuration."""
111
136
 
112
137
  env_prefix = ""
113
138
  extra = "forbid"
139
+ hide_input_in_errors = True
114
140
 
115
141
  @classmethod
116
142
  def from_env_file(cls, filename: pathlib.Path) -> "EnvConfig":
pyobservability/main.py CHANGED
@@ -1,8 +1,8 @@
1
1
  import logging
2
- import os
3
2
  import pathlib
4
3
  import warnings
5
4
  from datetime import datetime
5
+ from typing import Dict
6
6
 
7
7
  import uiauth
8
8
  import uvicorn
@@ -34,24 +34,49 @@ async def index(request: Request):
34
34
 
35
35
  Args:
36
36
  request: FastAPI request object.
37
+
38
+ Returns:
39
+ TemplateResponse:
40
+ Rendered HTML template with targets and version.
37
41
  """
38
- return templates.TemplateResponse(
39
- "index.html", {"request": request, "targets": settings.env.targets, "version": __version__}
40
- )
42
+ args = dict(request=request, targets=settings.env.targets, version=__version__)
43
+ if settings.env.username and settings.env.password:
44
+ args["logout"] = uiauth.enums.APIEndpoints.fastapi_logout.value
45
+ return templates.TemplateResponse("index.html", args)
46
+
47
+
48
+ async def health() -> Dict[str, str]:
49
+ """Health check endpoint.
50
+
51
+ Returns:
52
+ dict:
53
+ Health status.
54
+ """
55
+ return {"status": "ok"}
41
56
 
42
57
 
43
- def include_routes():
58
+ def include_routes() -> None:
59
+ """Include routes in the FastAPI app with or without authentication."""
60
+ PyObservability.routes.append(
61
+ APIRoute(
62
+ path=enums.APIEndpoints.health,
63
+ endpoint=health,
64
+ methods=["GET"],
65
+ include_in_schema=False,
66
+ ),
67
+ )
44
68
  if all((settings.env.username, settings.env.password)):
45
69
  uiauth.protect(
46
70
  app=PyObservability,
47
71
  username=settings.env.username,
48
72
  password=settings.env.password,
73
+ timeout=settings.env.timeout,
49
74
  custom_logger=LOGGER,
50
75
  params=[
51
76
  uiauth.Parameters(
52
77
  path=enums.APIEndpoints.root,
53
78
  function=index,
54
- methods=["GET"],
79
+ methods=[uiauth.enums.APIMethods.GET],
55
80
  ),
56
81
  uiauth.Parameters(
57
82
  path=enums.APIEndpoints.ws,
@@ -78,7 +103,8 @@ def include_routes():
78
103
  )
79
104
 
80
105
 
81
- def start(**kwargs):
106
+ def start(**kwargs) -> None:
107
+ """Start the FastAPI app with Uvicorn server."""
82
108
  settings.env = settings.env_loader(**kwargs)
83
109
  settings.env.targets = [{k: str(v) for k, v in target.model_dump().items()} for target in settings.env.targets]
84
110
  settings.targets_by_url = {t["base_url"]: t for t in settings.env.targets}
@@ -88,13 +114,16 @@ def start(**kwargs):
88
114
  port=settings.env.port,
89
115
  app=PyObservability,
90
116
  )
91
- if log := settings.env.log:
92
- if log == enums.Log.stdout:
117
+ if settings.env.log:
118
+ if settings.env.log == enums.Log.stdout:
93
119
  uvicorn_args["log_config"] = settings.detailed_log_config(debug=settings.env.debug)
94
120
  else:
95
- log_file = datetime.now().strftime(os.path.join("logs", "pyobservability_%d-%m-%Y.log"))
96
- os.makedirs("logs", exist_ok=True)
97
- uvicorn_args["log_config"] = settings.detailed_log_config(filename=log_file, debug=settings.env.debug)
121
+ logs_path = pathlib.Path(settings.env.logs_path)
122
+ log_file = logs_path / f"pyobservability_{datetime.now():%d-%m-%Y}.log"
123
+ logs_path.mkdir(parents=True, exist_ok=True)
124
+ uvicorn_args["log_config"] = settings.detailed_log_config(
125
+ filename=log_file.resolve(), debug=settings.env.debug
126
+ )
98
127
  # log_config will take precedence if both log and log_config are set
99
128
  if settings.env.log_config:
100
129
  uvicorn_args["log_config"] = (
@@ -3,7 +3,7 @@ import json
3
3
  import logging
4
4
  from asyncio import CancelledError
5
5
  from collections.abc import Generator
6
- from typing import Any, Dict, List
6
+ from typing import Any, AsyncGenerator, Dict, List
7
7
 
8
8
  import aiohttp
9
9
 
@@ -14,6 +14,15 @@ OBS_PATH = "/observability"
14
14
 
15
15
 
16
16
  def refine_service(service_list: List[Dict[str, Any]]) -> Generator[Dict[str, Dict[str, str]]]:
17
+ """Refine service stats to only include relevant fields and round CPU values.
18
+
19
+ Args:
20
+ service_list: List of service statistics dictionaries.
21
+
22
+ Yields:
23
+ Dict[str, Dict[str, str]]:
24
+ Refined service statistics dictionary.
25
+ """
17
26
  for service in service_list:
18
27
  service["memory"] = dict(rss=service.get("memory", {}).get("rss"), vms=service.get("memory", {}).get("vms"))
19
28
  service["cpu"] = dict(
@@ -24,7 +33,18 @@ def refine_service(service_list: List[Dict[str, Any]]) -> Generator[Dict[str, Di
24
33
 
25
34
 
26
35
  class Monitor:
36
+ """Monitor class to stream observability data from a target.
37
+
38
+ >>> Monitor
39
+
40
+ """
41
+
27
42
  def __init__(self, target: Dict[str, str]):
43
+ """Initialize Monitor with target configuration.
44
+
45
+ Args:
46
+ target: Dictionary containing target configuration with keys 'name', 'base_url', and 'apikey'.
47
+ """
28
48
  self.name = target["name"]
29
49
  self.base_url = target["base_url"]
30
50
  self.apikey = target["apikey"]
@@ -40,11 +60,22 @@ class Monitor:
40
60
  # SUBSCRIBE / UNSUBSCRIBE
41
61
  # ------------------------------
42
62
  def subscribe(self) -> asyncio.Queue:
63
+ """Subscribe to the monitor's data stream.
64
+
65
+ Returns:
66
+ asyncio.Queue:
67
+ Queue to receive streamed data.
68
+ """
43
69
  q = asyncio.Queue(maxsize=10)
44
70
  self._ws_subscribers.append(q)
45
71
  return q
46
72
 
47
- def unsubscribe(self, q: asyncio.Queue):
73
+ def unsubscribe(self, q: asyncio.Queue) -> None:
74
+ """Unsubscribe from the monitor's data stream.
75
+
76
+ Args:
77
+ q: Queue to be removed from subscribers.
78
+ """
48
79
  if q in self._ws_subscribers:
49
80
  self._ws_subscribers.remove(q)
50
81
 
@@ -52,6 +83,7 @@ class Monitor:
52
83
  # START / STOP
53
84
  # ------------------------------
54
85
  async def start(self):
86
+ """Start the monitor's data streaming."""
55
87
  if self._task:
56
88
  return # already running
57
89
 
@@ -61,6 +93,7 @@ class Monitor:
61
93
  self._task = asyncio.create_task(self._stream_target())
62
94
 
63
95
  async def stop(self):
96
+ """Stop the monitor's data streaming."""
64
97
  self._stop.set()
65
98
  if self._task:
66
99
  self._task.cancel()
@@ -74,7 +107,8 @@ class Monitor:
74
107
  await self.session.close()
75
108
  self.session = None
76
109
 
77
- async def update_flags(self, **kwargs):
110
+ async def update_flags(self, **kwargs) -> None:
111
+ """Update monitor flags and restart the stream."""
78
112
  for k, v in kwargs.items():
79
113
  if k in self.flags:
80
114
  self.flags[k] = v
@@ -82,14 +116,21 @@ class Monitor:
82
116
  # restart stream with new params
83
117
  await self._restart_stream()
84
118
 
85
- async def _restart_stream(self):
119
+ async def _restart_stream(self) -> None:
120
+ """Restart the monitor's data streaming."""
86
121
  await self.stop()
87
122
  await self.start()
88
123
 
89
124
  # ------------------------------
90
125
  # FETCH STREAM
91
126
  # ------------------------------
92
- async def _fetch_stream(self):
127
+ async def _fetch_stream(self) -> AsyncGenerator[Dict[str, Any], None]:
128
+ """Fetch the observability data stream from the target.
129
+
130
+ Yields:
131
+ Dict[str, Any]:
132
+ Parsed observability data.
133
+ """
93
134
  query = f"?interval={settings.env.interval}"
94
135
  if self.flags["all_services"]:
95
136
  query += "&all_services=true"
@@ -122,7 +163,8 @@ class Monitor:
122
163
  # ------------------------------
123
164
  # STREAM LOOP
124
165
  # ------------------------------
125
- async def _stream_target(self):
166
+ async def _stream_target(self) -> None:
167
+ """Stream observability data from the target and notify subscribers."""
126
168
  errors = {}
127
169
  while not self._stop.is_set():
128
170
  try: