PyObservability 0.0.0a1__py3-none-any.whl → 0.0.0a2__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.
@@ -0,0 +1,68 @@
1
+ """Module for packaging."""
2
+
3
+ import sys
4
+
5
+ from pyobservability.main import start # noqa: F401
6
+ from pyobservability.version import __version__
7
+
8
+
9
+ def _cli() -> None:
10
+ """Starter function to invoke the observability UI via CLI commands.
11
+
12
+ **Flags**
13
+ - ``--version | -V``: Prints the version.
14
+ - ``--help | -H``: Prints the help section.
15
+ - ``--env | -E <path>``: Filepath to load environment variables.
16
+
17
+ **Commands**
18
+ ``start``: Initiates the PyObservability as a regular script.
19
+ """
20
+ assert sys.argv[0].endswith("pyobservability"), "Invalid commandline trigger!!"
21
+ options = {
22
+ "--version | -V": "Prints the version.",
23
+ "--help | -H": "Prints the help section.",
24
+ "--env | -E <path>": "Filepath to load environment variables.",
25
+ "start": "Initiates the PyObservability as a regular script.",
26
+ }
27
+ # weird way to increase spacing to keep all values monotonic
28
+ _longest_key = len(max(options.keys()))
29
+ _pretext = "\n\t* "
30
+ choices = _pretext + _pretext.join(
31
+ f"{k} {'·' * (_longest_key - len(k) + 8)}→ {v}".expandtabs() for k, v in options.items()
32
+ )
33
+ args = [arg.lower() for arg in sys.argv[1:]]
34
+ try:
35
+ assert len(args) > 1
36
+ except (IndexError, AttributeError, AssertionError):
37
+ print(f"Cannot proceed without a valid arbitrary command. Please choose from {choices}")
38
+ exit(1)
39
+ env_file = None
40
+ if any(arg in args for arg in ["version", "--version", "-v"]):
41
+ print(f"PyObservability: {__version__}")
42
+ exit(0)
43
+ elif any(arg in args for arg in ["help", "--help", "-h"]):
44
+ print(f"Usage: pyobservability [arbitrary-command]\nOptions (and corresponding behavior):{choices}")
45
+ exit(0)
46
+ elif any(arg in args for arg in ["env", "--env", "E", "-e"]):
47
+ extra_index = next(
48
+ (index for index, arg in enumerate(args) if arg in ["env", "--env", "E", "-e"]),
49
+ None,
50
+ )
51
+ try:
52
+ env_file = sys.argv[extra_index + 2]
53
+ except (IndexError, TypeError):
54
+ print("Cannot proceed without a valid extra environment file path.")
55
+ exit(1)
56
+ elif any(arg in args for arg in ("start",)):
57
+ pass
58
+ else:
59
+ print(f"Unknown Option: {sys.argv[1]}\nArbitrary commands must be one of {choices}")
60
+ exit(1)
61
+ if any(arg in args for arg in ("start",)):
62
+ start(env_file=env_file)
63
+ else:
64
+ print(
65
+ "Insufficient Arguments:\n\tNo command received to initiate the PyObservability. "
66
+ f"Please choose from {choices}"
67
+ )
68
+ exit(1)
@@ -0,0 +1,108 @@
1
+ import json
2
+ import pathlib
3
+ import socket
4
+ from typing import List
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, HttpUrl, PositiveInt
8
+ from pydantic_settings import BaseSettings
9
+
10
+
11
+ class PydanticEnvConfig(BaseSettings):
12
+ """Pydantic BaseSettings with custom order for loading environment variables.
13
+
14
+ >>> PydanticEnvConfig
15
+
16
+ """
17
+
18
+ @classmethod
19
+ def settings_customise_sources(
20
+ cls,
21
+ settings_cls,
22
+ init_settings,
23
+ env_settings,
24
+ dotenv_settings,
25
+ file_secret_settings,
26
+ ):
27
+ """Order: dotenv, env, init, secrets files."""
28
+ return dotenv_settings, env_settings, init_settings, file_secret_settings
29
+
30
+
31
+ class MonitorTarget(BaseModel):
32
+ name: str
33
+ base_url: HttpUrl
34
+ apikey: str
35
+
36
+
37
+ class EnvConfig(PydanticEnvConfig):
38
+ """Configuration settings for the server.
39
+
40
+ >>> EnvConfig
41
+
42
+ """
43
+
44
+ host: str = socket.gethostbyname("localhost") or "0.0.0.0"
45
+ port: PositiveInt = 8080
46
+
47
+ monitor_targets: List[MonitorTarget]
48
+ poll_interval: PositiveInt = 3
49
+
50
+ class Config:
51
+ """Environment variables configuration."""
52
+
53
+ env_prefix = ""
54
+ extra = "forbid"
55
+
56
+ @classmethod
57
+ def from_env_file(cls, filename: pathlib.Path) -> "EnvConfig":
58
+ """Create an instance of EnvConfig from environment file.
59
+
60
+ Args:
61
+ filename: Name of the env file.
62
+
63
+ Returns:
64
+ EnvConfig:
65
+ Loads the ``EnvConfig`` model.
66
+ """
67
+ # noinspection PyArgumentList
68
+ return cls(_env_file=filename)
69
+
70
+
71
+ def env_loader(**kwargs) -> EnvConfig:
72
+ """Loads environment variables based on filetypes or kwargs.
73
+
74
+ Returns:
75
+ EnvConfig:
76
+ Returns a reference to the ``EnvConfig`` object.
77
+ """
78
+ # Default to .env if no kwargs were passed
79
+ if not kwargs:
80
+ return EnvConfig.from_env_file(".env")
81
+ # Look for the kwarg env_file and process accordingly
82
+ if env_file := kwargs.get("env_file"):
83
+ env_file = pathlib.Path(env_file)
84
+ assert env_file.is_file(), f"\n\tenv_file: [{env_file.resolve()!r}] does not exist"
85
+ if env_file.suffix.lower() == ".json":
86
+ with env_file.open() as stream:
87
+ env_data = json.load(stream)
88
+ return EnvConfig(**{k.lower(): v for k, v in env_data.items()})
89
+ elif env_file.suffix.lower() in (".yaml", ".yml"):
90
+ with env_file.open() as stream:
91
+ env_data = yaml.load(stream, yaml.FullLoader)
92
+ return EnvConfig(**{k.lower(): v for k, v in env_data.items()})
93
+ elif not env_file.suffix or env_file.suffix.lower() in (
94
+ ".text",
95
+ ".txt",
96
+ ".env",
97
+ "",
98
+ ):
99
+ return EnvConfig.from_env_file(env_file)
100
+ else:
101
+ raise ValueError(
102
+ f"\n\tUnsupported format for {env_file!r}, " "can be one of (.json, .yaml, .yml, .txt, .text, .env)"
103
+ )
104
+ # Load env config with regular kwargs
105
+ return EnvConfig(**kwargs)
106
+
107
+
108
+ env: EnvConfig
pyobservability/main.py CHANGED
@@ -1,45 +1,45 @@
1
1
  import logging
2
- import os
3
2
  import pathlib
4
3
 
5
- import dotenv
4
+ import uvicorn
6
5
  from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
6
+ from fastapi.routing import APIRoute, APIWebSocketRoute
7
7
  from fastapi.staticfiles import StaticFiles
8
8
  from fastapi.templating import Jinja2Templates
9
9
 
10
+ from pyobservability.config import settings
10
11
  from pyobservability.monitor import Monitor
11
12
 
12
- dotenv.load_dotenv()
13
-
14
13
  LOGGER = logging.getLogger("uvicorn.default")
15
14
 
16
- app = FastAPI(title="Monitor UI")
15
+ PyObservability = FastAPI(title="PyObservability")
16
+ PyObservability.__name__ = "PyObservability"
17
+ PyObservability.description = "Observability page for nodes running PyNinja"
18
+
17
19
  root = pathlib.Path(__file__).parent
18
20
  templates_dir = root / "templates"
19
- static_dir = root / "static"
20
21
  templates = Jinja2Templates(directory=templates_dir)
21
- app.mount("/static", StaticFiles(directory=static_dir), name="static")
22
22
 
23
- monitor = Monitor(poll_interval=float(os.getenv("POLL_INTERVAL", 2)))
23
+ static_dir = root / "static"
24
+ PyObservability.mount("/static", StaticFiles(directory=static_dir), name="static")
24
25
 
25
26
 
26
- @app.get("/")
27
27
  async def index(request: Request):
28
28
  """Pass configured targets to the template so frontend can prebuild UI.
29
29
 
30
30
  Args:
31
31
  request: FastAPI request object.
32
32
  """
33
- return templates.TemplateResponse("index.html", {"request": request, "targets": monitor.targets})
33
+ return templates.TemplateResponse("index.html", {"request": request, "targets": settings.env.monitor_targets})
34
34
 
35
35
 
36
- @app.websocket("/ws")
37
36
  async def websocket_endpoint(websocket: WebSocket):
38
37
  """Websocket endpoint to render the metrics.
39
38
 
40
39
  Args:
41
40
  websocket: FastAPI websocket object.
42
41
  """
42
+ monitor = Monitor(targets=settings.env.monitor_targets, poll_interval=settings.env.poll_interval)
43
43
  await websocket.accept()
44
44
  await monitor.start()
45
45
  q = monitor.subscribe()
@@ -56,5 +56,33 @@ async def websocket_endpoint(websocket: WebSocket):
56
56
  await websocket.close()
57
57
  except Exception as err:
58
58
  LOGGER.warning(err)
59
- pass
60
59
  await monitor.stop()
60
+
61
+
62
+ PyObservability.routes.append(
63
+ APIRoute(
64
+ path="/", # enums.APIEndpoints.root,
65
+ endpoint=index,
66
+ methods=["GET"],
67
+ include_in_schema=False,
68
+ ),
69
+ )
70
+ PyObservability.routes.append(
71
+ APIWebSocketRoute(
72
+ path="/ws",
73
+ endpoint=websocket_endpoint,
74
+ )
75
+ )
76
+
77
+
78
+ def start(**kwargs):
79
+ settings.env = settings.env_loader(**kwargs)
80
+ settings.env.monitor_targets = [
81
+ {k: str(v) for k, v in target.model_dump().items()} for target in settings.env.monitor_targets
82
+ ]
83
+ uvicorn_args = dict(
84
+ host=settings.env.host,
85
+ port=settings.env.port,
86
+ app=PyObservability,
87
+ )
88
+ uvicorn.run(**uvicorn_args)
@@ -1,9 +1,7 @@
1
1
  # app/monitor.py
2
2
 
3
3
  import asyncio
4
- import json
5
4
  import logging
6
- import os
7
5
  from asyncio import CancelledError
8
6
  from typing import Any, Dict, List
9
7
  from urllib.parse import urlparse
@@ -60,42 +58,6 @@ ENDPOINTS = {
60
58
  }
61
59
 
62
60
 
63
- ###############################################################################
64
- # LOAD TARGETS FROM ENV
65
- ###############################################################################
66
-
67
-
68
- def load_targets_from_env() -> List[Dict[str, Any]]:
69
- """Loads monitor targets from environment variables and parses it.
70
-
71
- Returns:
72
- List[Dict[str, Any]]:
73
- Returns the parsed list of the monitor targets.
74
- """
75
- raw = os.getenv("MONITOR_TARGETS", "[]")
76
-
77
- try:
78
- data = json.loads(raw)
79
- except Exception:
80
- data = [raw] if raw else []
81
-
82
- parsed = []
83
-
84
- for entry in data:
85
- if isinstance(entry, str):
86
- parsed.append({"name": entry, "base_url": entry, "apikey": None})
87
- elif isinstance(entry, dict):
88
- parsed.append(
89
- {
90
- "name": entry.get("name") or entry["base_url"],
91
- "base_url": entry["base_url"],
92
- "apikey": entry.get("apikey"),
93
- }
94
- )
95
-
96
- return parsed
97
-
98
-
99
61
  ###############################################################################
100
62
  # MONITOR CLASS
101
63
  ###############################################################################
@@ -103,9 +65,9 @@ def load_targets_from_env() -> List[Dict[str, Any]]:
103
65
 
104
66
  class Monitor:
105
67
 
106
- def __init__(self, poll_interval: float = 2.0):
107
- self.targets = load_targets_from_env()
108
- self.poll_interval = float(os.getenv("POLL_INTERVAL", poll_interval))
68
+ def __init__(self, targets: List[Dict[str, str]], poll_interval: float):
69
+ self.targets = targets
70
+ self.poll_interval = poll_interval
109
71
  self.sessions: Dict[str, aiohttp.ClientSession] = {}
110
72
  self._ws_subscribers: List[asyncio.Queue] = []
111
73
  self._task = None
@@ -115,8 +77,8 @@ class Monitor:
115
77
  # LIFECYCLE
116
78
  ############################################################################
117
79
  async def start(self):
118
- for t in self.targets:
119
- self.sessions[t["base_url"]] = aiohttp.ClientSession()
80
+ for target in self.targets:
81
+ self.sessions[target["base_url"]] = aiohttp.ClientSession()
120
82
  self._task = asyncio.create_task(self._run_loop())
121
83
 
122
84
  async def stop(self):
@@ -148,12 +110,8 @@ class Monitor:
148
110
  ############################################################################
149
111
  # FETCH WRAPPER
150
112
  ############################################################################
151
- async def _fetch(self, session, base_url, ep, apikey=None, params=None):
113
+ async def _fetch(self, session, base_url, ep, headers: Dict[str, str], params=None):
152
114
  url = base_url.rstrip("/") + ep
153
- headers = {"accept": "application/json"}
154
- if apikey:
155
- headers["Authorization"] = f"Bearer {apikey}"
156
-
157
115
  try:
158
116
  async with session.get(url, headers=headers, params=params, timeout=10) as resp:
159
117
  if resp.status == 200:
@@ -174,8 +132,9 @@ class Monitor:
174
132
  ############################################################################
175
133
  async def _poll_target(self, target: Dict[str, Any]) -> Dict[str, Any]:
176
134
  base = target["base_url"]
177
- apikey = target.get("apikey")
135
+ apikey = target["apikey"]
178
136
  session = self.sessions[base]
137
+ headers = {"Accept": "application/json", "Authorization": f"Bearer {apikey}"}
179
138
 
180
139
  result = {"name": target["name"], "base_url": base, "metrics": {}}
181
140
 
@@ -184,7 +143,7 @@ class Monitor:
184
143
 
185
144
  for key, cfg in ENDPOINTS.items():
186
145
  tasks[key] = asyncio.create_task(
187
- self._fetch(session, base, cfg["path"], apikey=apikey, params=cfg["params"])
146
+ self._fetch(session, base, cfg["path"], headers=headers, params=cfg["params"])
188
147
  )
189
148
 
190
149
  # Wait for all endpoints
@@ -206,7 +165,7 @@ class Monitor:
206
165
  # POLL ALL HOSTS
207
166
  ############################################################################
208
167
  async def _poll_all(self) -> List[Dict[str, Any]]:
209
- tasks = [self._poll_target(t) for t in self.targets]
168
+ tasks = [self._poll_target(target) for target in self.targets]
210
169
  results = await asyncio.gather(*tasks, return_exceptions=True)
211
170
  out = []
212
171
  for r in results:
@@ -1 +1 @@
1
- __version__ = "0.0.0a1"
1
+ __version__ = "0.0.0a2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyObservability
3
- Version: 0.0.0a1
3
+ Version: 0.0.0a2
4
4
  Summary: Lightweight OS-agnostic observability UI for PyNinja
5
5
  Author-email: Vignesh Rao <svignesh1793@gmail.com>
6
6
  License: MIT License
@@ -41,11 +41,13 @@ Classifier: Topic :: System :: Monitoring
41
41
  Requires-Python: >=3.11
42
42
  Description-Content-Type: text/markdown
43
43
  License-File: LICENSE
44
- Requires-Dist: aiohttp
45
- Requires-Dist: fastapi
46
- Requires-Dist: jinja2
47
- Requires-Dist: python-dotenv
48
- Requires-Dist: uvicorn[standard]
44
+ Requires-Dist: aiohttp==3.13.*
45
+ Requires-Dist: fastapi==0.122.*
46
+ Requires-Dist: Jinja2==3.1.*
47
+ Requires-Dist: pydantic==2.12.*
48
+ Requires-Dist: pydantic-settings==2.12.*
49
+ Requires-Dist: python-dotenv==1.2.*
50
+ Requires-Dist: uvicorn[standard]==0.38.*
49
51
  Provides-Extra: dev
50
52
  Requires-Dist: sphinx==5.1.1; extra == "dev"
51
53
  Requires-Dist: pre-commit; extra == "dev"
@@ -0,0 +1,14 @@
1
+ pyobservability/__init__.py,sha256=rr4udGMbbNPl3yo7l8R3FUUVVahBtYVaW6vSWWgXlv0,2617
2
+ pyobservability/main.py,sha256=m3jNBQ7B495d1Pk_Fcdy3AQbNts2K8iFwDQDKV1pB0M,2527
3
+ pyobservability/monitor.py,sha256=s2sVp97sLjkkdtL6be82bX5ydu_gBdMSoWxDlmUtpgE,6613
4
+ pyobservability/version.py,sha256=za-UuO_D1PzxYCprpyh75AnwaFhntPuAZpHRqS1fIxc,24
5
+ pyobservability/config/settings.py,sha256=53dYdfO5SbmHQ4cLzPM2JQvrU2Lw70vBghlhiLy28ZI,3013
6
+ pyobservability/static/app.js,sha256=poc7eReoiRUbyI5JKnPwxSqmSNCuOge4aZKCITFy7eo,13494
7
+ pyobservability/static/styles.css,sha256=t6r1C0ueBanipgRRjdu18nmq6RbSGLK5bhpf0BdMOpQ,3245
8
+ pyobservability/templates/index.html,sha256=PsN3aq-7Q6RzGeshoNH5v37G3sHoI2saJq6mfuA6JYs,3977
9
+ pyobservability-0.0.0a2.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
10
+ pyobservability-0.0.0a2.dist-info/METADATA,sha256=y74dxaDkt1pTwgqFmkNj7bemJqOPqKUjfHBHPwWh0UA,2775
11
+ pyobservability-0.0.0a2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ pyobservability-0.0.0a2.dist-info/entry_points.txt,sha256=DSGIr_VA8Tb3FYa2iNUYpf55eAvuFCAoInNS4ngXaME,57
13
+ pyobservability-0.0.0a2.dist-info/top_level.txt,sha256=p20T0EmihDYW1uMintRXr7X9bg3XWYKyoSbBHOVC1xI,16
14
+ pyobservability-0.0.0a2.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyobservability = pyobservability:_cli
@@ -1,12 +0,0 @@
1
- pyobservability/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- pyobservability/main.py,sha256=XzH2XNuxU9lbFSnQXsJqJ2ytrd6nnzbTf7B0n0UQuW0,1637
3
- pyobservability/monitor.py,sha256=nnL6odqO3BF-XH1wek0leyMKXw7NB8qrwp6aAh-xJyA,7694
4
- pyobservability/version.py,sha256=PvT0JaYWREcshCnUiHC2CMpUMuTV359GmoET7cuhPiU,24
5
- pyobservability/static/app.js,sha256=poc7eReoiRUbyI5JKnPwxSqmSNCuOge4aZKCITFy7eo,13494
6
- pyobservability/static/styles.css,sha256=t6r1C0ueBanipgRRjdu18nmq6RbSGLK5bhpf0BdMOpQ,3245
7
- pyobservability/templates/index.html,sha256=PsN3aq-7Q6RzGeshoNH5v37G3sHoI2saJq6mfuA6JYs,3977
8
- pyobservability-0.0.0a1.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
9
- pyobservability-0.0.0a1.dist-info/METADATA,sha256=Ze1eq09JsbqSTcpPFIrvMQxKJubLqKxzAxcMhLIzbzw,2663
10
- pyobservability-0.0.0a1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
- pyobservability-0.0.0a1.dist-info/top_level.txt,sha256=p20T0EmihDYW1uMintRXr7X9bg3XWYKyoSbBHOVC1xI,16
12
- pyobservability-0.0.0a1.dist-info/RECORD,,