bear-utils 0.8.26__py3-none-any.whl → 0.9.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.
@@ -1,209 +1,334 @@
1
1
  """FastAPI-based local logging server and HTTP logger."""
2
2
 
3
3
  import asyncio
4
- import sys
4
+ from collections import deque
5
+ from pathlib import Path
5
6
  import threading
6
- from typing import TYPE_CHECKING
7
+ from typing import TYPE_CHECKING, Any, Self, TextIO
7
8
 
8
9
  from fastapi import FastAPI
10
+ from fastapi.responses import JSONResponse
9
11
  from httpx import AsyncClient
10
- from pydantic import BaseModel
12
+ from pydantic import BaseModel, Field, field_serializer
13
+ from singleton_base import SingletonBase
11
14
  import uvicorn
12
15
 
13
- from bear_utils.constants import SERVER_OK
14
- from bear_utils.logger_manager._log_level import LogLevel, log_levels
16
+ from bear_utils.constants import DEVNULL, ExitCode, HTTPStatusCode
17
+ from bear_utils.logger_manager._log_level import LogLevel
15
18
  from bear_utils.time import EpochTimestamp
16
19
 
17
20
  if TYPE_CHECKING:
18
21
  from httpx import Response
19
22
 
20
23
 
21
- VERBOSE: LogLevel = log_levels.get("VERBOSE")
22
- DEBUG: LogLevel = log_levels.get("DEBUG")
23
- INFO: LogLevel = log_levels.get("INFO")
24
- WARNING: LogLevel = log_levels.get("WARNING")
25
- ERROR: LogLevel = log_levels.get("ERROR")
26
-
27
-
28
- def get_level(level: str) -> int:
29
- """Get the numeric value for a given level string."""
30
- return log_levels.get_int(level)
31
-
32
-
33
- def get_name(level: int) -> str:
34
- """Get the name of a logging level by its integer value."""
35
- return log_levels.get_name(level)
24
+ VERBOSE: LogLevel = LogLevel.from_name("VERBOSE")
25
+ DEBUG: LogLevel = LogLevel.from_name("DEBUG")
26
+ INFO: LogLevel = LogLevel.from_name("INFO")
27
+ WARNING: LogLevel = LogLevel.from_name("WARNING")
28
+ ERROR: LogLevel = LogLevel.from_name("ERROR")
29
+ FAILURE: LogLevel = LogLevel.from_name("FAILURE")
30
+ SUCCESS: LogLevel = LogLevel.from_name("SUCCESS")
36
31
 
37
32
 
38
33
  class LogRequest(BaseModel):
39
34
  """Request model for logging messages."""
40
35
 
41
- level: str
42
- message: str
43
- args: list[str] = []
44
- kwargs: dict[str, str] = {}
36
+ level: LogLevel | int | str = Field(default=DEBUG, description="Log level of the message")
37
+ message: str = Field(default="", description="Log message content")
38
+ args: tuple = Field(default=(), description="Arguments for the log message")
39
+ kwargs: dict[str, str] = Field(default_factory=dict, description="Keyword arguments for the log message")
45
40
 
41
+ model_config = {
42
+ "arbitrary_types_allowed": True,
43
+ }
46
44
 
47
- class LocalLoggingServer:
45
+ @field_serializer("level")
46
+ def serialize_level(self, value: LogLevel | int | str) -> int:
47
+ """Serialize the log level to an integer."""
48
+ return LogLevel.get(value).value
49
+
50
+
51
+ class LoggingServer[T: TextIO](SingletonBase):
48
52
  """A local server that writes logs to a file."""
49
53
 
50
54
  def __init__(
51
55
  self,
52
56
  host: str = "localhost",
53
57
  port: int = 8080,
54
- log_file: str = "server.log",
55
- min_level: LogLevel | int | str = DEBUG,
58
+ log_file: str | Path = "server.log",
59
+ level: LogLevel | int | str = DEBUG,
60
+ file: T = DEVNULL, # Default to DEVNULL to discard console output
61
+ maxlen: int = 100,
56
62
  ) -> None:
57
63
  """Initialize the logging server."""
58
64
  self.host: str = host
59
65
  self.port: int = port
60
- self.log_file: str = log_file
61
- self.min_level: LogLevel = log_levels.get(min_level)
66
+ self.log_file: Path = Path(log_file)
67
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
68
+ self.level: LogLevel = LogLevel.get(level)
62
69
  self.app = FastAPI()
63
70
  self.server_thread = None
64
71
  self._running = False
72
+ self.logs: deque[LogRequest] = deque(maxlen=maxlen)
73
+ self.file: T = file
65
74
  self._setup_routes()
66
- self.buffer: list[str] = []
75
+
76
+ @property
77
+ def running(self) -> bool:
78
+ """Check if the server is running."""
79
+ return self._running or (self.server_thread is not None and self.server_thread.is_alive())
80
+
81
+ def __len__(self) -> int:
82
+ """Get the number of logged messages."""
83
+ return len(self.logs)
84
+
85
+ def get_logs(self) -> list[LogRequest]:
86
+ """Get the list of logged messages."""
87
+ return list(self.logs)
88
+
89
+ def print(self, msg: object, end: str = "\n") -> None:
90
+ """Print the message to the specified file with an optional end character."""
91
+ print(msg, end=end, file=self.file)
92
+
93
+ def response(
94
+ self,
95
+ status: str,
96
+ message: str = "",
97
+ status_code: HTTPStatusCode = HTTPStatusCode.SERVER_OK,
98
+ ) -> JSONResponse:
99
+ """Create a JSON response with the given content and status code."""
100
+ return JSONResponse(content={"status": status, "message": message}, status_code=status_code.value)
67
101
 
68
102
  def _setup_routes(self) -> None:
69
103
  """Set up the FastAPI routes for logging and health check."""
70
104
 
71
105
  @self.app.post("/log")
72
- async def log_message(request: LogRequest) -> dict[str, str]:
73
- self.write_log(request.level, request.message, *request.args, **request.kwargs)
74
- return {"status": "success"}
106
+ async def log_message(request: LogRequest | Any) -> JSONResponse:
107
+ """Endpoint to log a message."""
108
+ request = LogRequest(
109
+ level=request["level"] if isinstance(request, dict) else request.level,
110
+ message=request["message"] if isinstance(request, dict) else request.message,
111
+ args=request["args"] if isinstance(request, dict) else request.args,
112
+ kwargs=request["kwargs"] if isinstance(request, dict) else request.kwargs,
113
+ )
114
+ level: LogLevel = LogLevel.get(request.level)
115
+ if level.value < self.level.value:
116
+ return self.response(status="ignored", message="Log level is lower than server's minimum level")
117
+ message = request.message
118
+ args = request.args
119
+ kwargs: dict[str, str] | Any = request.kwargs
120
+ success: ExitCode = self.write_log(level, message, *args, **kwargs)
121
+ self.logs.append(request)
122
+ if success != ExitCode.SUCCESS:
123
+ return self.response(
124
+ status="error", message="Failed to write log", status_code=HTTPStatusCode.SERVER_ERROR
125
+ )
126
+ return self.response(status="success", status_code=HTTPStatusCode.SERVER_OK)
75
127
 
76
128
  @self.app.get("/health")
77
- async def health_check() -> dict[str, str]:
78
- return {"status": "healthy"}
129
+ async def health_check() -> JSONResponse:
130
+ return JSONResponse(
131
+ content={"status": "healthy"},
132
+ status_code=HTTPStatusCode.SERVER_OK,
133
+ )
79
134
 
80
- def write_log(self, level: str, message: str, *args: str, end: str = "\n", **kwargs: str) -> None:
135
+ def write_log(
136
+ self,
137
+ level: LogLevel,
138
+ message: str,
139
+ end: str = "\n",
140
+ console: bool = True,
141
+ *args,
142
+ **kwargs,
143
+ ) -> ExitCode:
81
144
  """Write a log entry to the file - same logic as original logger."""
82
145
  timestamp: str = EpochTimestamp.now().to_string()
146
+ log_entry: str = f"[{timestamp}] {level}: {message}"
147
+ buffer = []
83
148
  try:
84
- level_t: LogLevel = log_levels.get(level)
85
- if level_t.value >= self.min_level.value:
86
- log_entry: str = f"[{timestamp}] {level}: {message}"
87
- self.buffer.append(log_entry)
88
- if args:
89
- self.buffer.append(f"{end}".join(str(arg) for arg in args))
90
- if kwargs:
91
- for key, value in kwargs.items():
92
- self.buffer.append(f"{key}={value}{end}")
93
- with open(self.log_file, "a", encoding="utf-8") as f:
94
- f.writelines(self.buffer)
95
- print(f"{end}".join(self.buffer), file=sys.stderr)
149
+ buffer.append(log_entry)
150
+ if args:
151
+ buffer.append(f"{end}".join(str(arg) for arg in args))
152
+ if kwargs:
153
+ for key, value in kwargs.items():
154
+ buffer.append(f"{key}={value}{end}")
155
+ if console:
156
+ self.print(f"{end}".join(buffer))
157
+ with open(self.log_file, "a", encoding="utf-8") as f:
158
+ for line in buffer:
159
+ f.write(f"{line}{end}")
160
+ return ExitCode.SUCCESS
96
161
  except Exception:
97
- print(f"[{timestamp}] {level}: {message}", file=sys.stderr)
98
- finally:
99
- self.buffer.clear()
162
+ self.print(f"[{timestamp}] {level}: {message}")
163
+ return ExitCode.FAILURE
100
164
 
101
- def start(self) -> None:
165
+ async def start(self) -> None:
102
166
  """Start the logging server in a separate thread."""
103
167
  if self._running:
104
168
  return
105
169
 
106
- def run_server() -> None:
170
+ def _run_server() -> None:
107
171
  """Run the FastAPI server in a new event loop."""
108
- asyncio.set_event_loop(asyncio.new_event_loop())
109
- uvicorn.run(self.app, host=self.host, port=self.port, log_level="warning")
172
+ uvicorn.run(self.app, host=self.host, port=self.port, log_level="error")
110
173
 
111
- self.server_thread = threading.Thread(target=run_server)
174
+ self.server_thread = threading.Thread(target=_run_server)
112
175
  self.server_thread.daemon = True
113
176
  self.server_thread.start()
114
177
  self._running = True
115
- print(f"Logging server started on {self.host}:{self.port}")
178
+ self.write_log(DEBUG, f"Logging server started on {self.host}:{self.port}")
116
179
 
117
- def stop(self) -> None:
180
+ async def stop(self) -> None:
118
181
  """Stop the logging server."""
119
182
  if self._running:
120
183
  self._running = False
121
- print("Logging server stopped")
184
+ if self.server_thread is not None:
185
+ self.server_thread.join(timeout=1)
186
+ self.server_thread = None
187
+ self.write_log(DEBUG, "Logging server stopped")
188
+
189
+ async def __aenter__(self) -> Self:
190
+ """Start the logging server."""
191
+ if not self.running:
192
+ await self.start()
193
+ else:
194
+ self.write_log(DEBUG, "Logging server is already running")
195
+ return self
196
+
197
+ async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
198
+ """Stop the logging server."""
199
+ await self.stop()
122
200
 
123
201
 
124
- class ServerLogger:
202
+ class LoggingClient[T: TextIO]:
125
203
  """Logger that calls HTTP endpoints but behaves like SimpleLogger."""
126
204
 
127
- def __init__(self, server_url: str = "http://localhost:8080", min_level: LogLevel | int | str = INFO) -> None:
205
+ def __init__(
206
+ self,
207
+ server_url: str | None = None,
208
+ host: str = "http://localhost",
209
+ port: int = 8080,
210
+ level: LogLevel | int | str = INFO,
211
+ file: T = DEVNULL, # Default to DEVNULL to discard console output
212
+ ) -> None:
128
213
  """Initialize the ServerLogger."""
129
- self.server_url: str = server_url.rstrip("/")
130
- self.min_level: LogLevel = log_levels.get(min_level)
214
+ self.host: str = host
215
+ self.port: int = port
216
+ self.server_url: str = server_url or f"{self.host}:{self.port}"
217
+ self.level: LogLevel = LogLevel.get(level)
131
218
  self.client: AsyncClient = AsyncClient(timeout=5.0)
132
- self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
219
+ self.file: T = file
220
+
221
+ async def post(self, url: str, json: dict) -> "Response":
222
+ """Send a POST request to the server."""
223
+ return await self.client.post(url=url, json=json)
133
224
 
134
- async def _log(self, lvl: LogLevel, msg: object, *args, **kwargs) -> None:
225
+ async def _log(self, request: LogRequest) -> None:
135
226
  """Same interface as SimpleLogger._log but calls HTTP endpoint."""
136
- if lvl.value >= self.min_level.value:
137
- try:
138
- response: Response = await self.client.post(
139
- url=f"{self.server_url}/log",
140
- json={
141
- "level": lvl.value,
142
- "message": msg,
143
- "args": args,
144
- "kwargs": kwargs,
145
- },
146
- )
147
- if response.status_code != SERVER_OK:
148
- self._fallback_log(lvl, msg, *args, **kwargs)
149
- except Exception:
150
- self._fallback_log(lvl, msg, *args, **kwargs)
227
+ try:
228
+ response: Response = await self.post(url=f"{self.server_url}/log", json=request.model_dump())
229
+ if response.status_code != HTTPStatusCode.SERVER_OK:
230
+ await self._fallback_log(str(request.level), request.message, *request.args, **request.kwargs)
231
+ except Exception:
232
+ await self._fallback_log(str(request.level), request.message, *request.args, **request.kwargs)
233
+
234
+ async def log(self, level: LogLevel, msg: object, *args: Any, **kwargs: Any) -> None:
235
+ """Log a message at the specified level."""
236
+ if level.value >= self.level.value:
237
+ request = LogRequest(level=level.value, message=str(msg), args=args, kwargs=kwargs)
238
+ await self._log(request)
151
239
 
152
- def _fallback_log(self, lvl: LogLevel, msg: object, *args, **kwargs) -> None:
240
+ async def _fallback_log(self, lvl: str, msg: object, *args: Any, **kwargs: Any) -> None:
153
241
  """Fallback - same as original SimpleLogger._log."""
154
242
  timestamp: str = EpochTimestamp.now().to_string()
155
- print(f"[{timestamp}] {lvl.value}: {msg}", file=sys.stderr)
243
+ print(f"Fallback Logging: [{timestamp}] {lvl}: {msg}", file=self.file)
156
244
  if args:
157
- print(" ".join(str(arg) for arg in args), file=sys.stderr)
245
+ print(" ".join(str(arg) for arg in args), file=self.file)
158
246
  if kwargs:
159
247
  for key, value in kwargs.items():
160
- print(f"{key}={value}", file=sys.stderr)
248
+ print(f"{key}={value}", file=self.file)
161
249
 
162
250
  async def verbose(self, msg: object, *args, **kwargs) -> None:
163
251
  """Log a verbose message."""
164
- await self._log(VERBOSE, msg, *args, **kwargs)
252
+ await self.log(VERBOSE, msg, *args, **kwargs)
165
253
 
166
254
  async def debug(self, msg: object, *args, **kwargs) -> None:
167
255
  """Log a debug message."""
168
- await self._log(DEBUG, msg, *args, **kwargs)
256
+ await self.log(DEBUG, msg, *args, **kwargs)
169
257
 
170
258
  async def info(self, msg: object, *args, **kwargs) -> None:
171
259
  """Log an info message."""
172
- await self._log(INFO, msg, *args, **kwargs)
260
+ await self.log(INFO, msg, *args, **kwargs)
173
261
 
174
262
  async def warning(self, msg: object, *args, **kwargs) -> None:
175
263
  """Log a warning message."""
176
- await self._log(WARNING, msg, *args, **kwargs)
264
+ await self.log(WARNING, msg, *args, **kwargs)
177
265
 
178
266
  async def error(self, msg: object, *args, **kwargs) -> None:
179
267
  """Log an error message."""
180
- await self._log(ERROR, msg, *args, **kwargs)
268
+ await self.log(ERROR, msg, *args, **kwargs)
269
+
270
+ async def failure(self, msg: object, *args, **kwargs) -> None:
271
+ """Log a failure message."""
272
+ await self.log(FAILURE, msg, *args, **kwargs)
273
+
274
+ async def success(self, msg: object, *args, **kwargs) -> None:
275
+ """Log a success message."""
276
+ await self.log(SUCCESS, msg, *args, **kwargs)
181
277
 
182
278
  async def close(self) -> None:
183
279
  """Close the HTTP client."""
184
280
  await self.client.aclose()
185
281
 
186
-
187
- if __name__ == "__main__":
188
- server = LocalLoggingServer(port=8080, log_file="server.log")
282
+ async def __aenter__(self) -> Self:
283
+ """Enter the asynchronous context manager."""
284
+ return self
285
+
286
+ async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
287
+ """Exit the asynchronous context manager."""
288
+ await self.close()
289
+
290
+
291
+ async def run_server(
292
+ host: str = "localhost",
293
+ port: int = 8080,
294
+ log_file: str = "server.log",
295
+ level: LogLevel | int | str = DEBUG,
296
+ file: TextIO = DEVNULL,
297
+ ) -> ExitCode | int:
298
+ """Run the local logging server."""
299
+ server: LoggingServer[TextIO] = LoggingServer(
300
+ host=host,
301
+ port=port,
302
+ log_file=log_file,
303
+ level=level,
304
+ file=file,
305
+ )
189
306
  try:
190
307
  while True:
191
- server.start()
308
+ await server.start()
192
309
  except KeyboardInterrupt:
193
- print("Stopping server...")
194
- server.stop()
195
- sys.exit(0)
196
- # time.sleep(2)
197
-
198
- # # Use logger exactly like SimpleLogger
199
- # logger = HTTPLogger("http://localhost:8080")
200
-
201
- # logger.verbose("This is a verbose message")
202
- # logger.debug("This is a debug message")
203
- # logger.info("This is an info message")
204
- # logger.warning("This is a warning message")
205
- # logger.error("This is an error message")
206
-
207
- # logger.close()
208
- # time.sleep(1)
209
- # server.stop()
310
+ print("Stopping server...", file=server.file)
311
+ except Exception as e:
312
+ print(f"An error occurred: {e}", file=server.file)
313
+ return ExitCode.FAILURE
314
+ finally:
315
+ await server.stop()
316
+ return ExitCode.SUCCESS
317
+
318
+
319
+ def sync_server(
320
+ host: str = "localhost",
321
+ port: int = 8080,
322
+ log_file: str = "server.log",
323
+ level: LogLevel | int | str = DEBUG,
324
+ file: TextIO = DEVNULL,
325
+ ) -> ExitCode | int:
326
+ """Run the local logging server synchronously."""
327
+ loop = asyncio.get_event_loop()
328
+ asyncio.set_event_loop(loop)
329
+ return loop.run_until_complete(run_server(host, port, log_file, level, file))
330
+
331
+
332
+ # if __name__ == "__main__":
333
+ # exit_code = sync_server()
334
+ # sys.exit(exit_code)
@@ -1,30 +1,36 @@
1
1
  """Simple logger implementation with log levels and timestamped output."""
2
2
 
3
- import sys
3
+ from io import StringIO
4
4
  from typing import TextIO
5
5
 
6
- from bear_utils.logger_manager._log_level import LogLevel, log_levels
6
+ from bear_utils.constants import STDERR, STDOUT
7
+ from bear_utils.logger_manager._log_level import LogLevel
7
8
  from bear_utils.time import EpochTimestamp
8
9
 
9
- STDOUT: TextIO = sys.stdout
10
- STDERR: TextIO = sys.stderr
10
+ VERBOSE: LogLevel = LogLevel.get("VERBOSE")
11
+ DEBUG: LogLevel = LogLevel.get("DEBUG")
12
+ INFO: LogLevel = LogLevel.get("INFO")
13
+ WARNING: LogLevel = LogLevel.get("WARNING")
14
+ ERROR: LogLevel = LogLevel.get("ERROR")
15
+ SUCCESS: LogLevel = LogLevel.get("SUCCESS")
16
+ FAILURE: LogLevel = LogLevel.get("FAILURE")
11
17
 
12
- VERBOSE: LogLevel = log_levels.get("VERBOSE")
13
- DEBUG: LogLevel = log_levels.get("DEBUG")
14
- INFO: LogLevel = log_levels.get("INFO")
15
- WARNING: LogLevel = log_levels.get("WARNING")
16
- ERROR: LogLevel = log_levels.get("ERROR")
18
+ CHOICES = [STDOUT, STDERR, StringIO]
17
19
 
18
20
 
19
- class SimpleLogger:
20
- """A simple logger that writes messages to stderr (or STDOUT if preferred) with a timestamp."""
21
+ class SimpleLogger[T: TextIO]:
22
+ """A simple logger that writes messages to stdout, stderr, or StringIO with a timestamp."""
21
23
 
22
- def __init__(self, min_level: int | str | LogLevel = INFO, redirect: TextIO = STDERR) -> None:
23
- """Initialize the logger with a minimum log level."""
24
- self.min_level: LogLevel = log_levels.get(min_level)
25
- self.redirect: TextIO = redirect
24
+ def __init__(self, level: str | int | LogLevel = "DEBUG", file: T = STDERR) -> None:
25
+ """Initialize the logger with a minimum log level and output file."""
26
+ self.level: LogLevel = LogLevel.get(level)
27
+ self.file: T = file
26
28
  self.buffer: list[str] = []
27
29
 
30
+ def print(self, msg: object, end: str = "\n") -> None:
31
+ """Print the message to the specified file with an optional end character."""
32
+ print(msg, end=end, file=self.file)
33
+
28
34
  def _log(self, level: LogLevel, msg: object, end: str = "\n", *args, **kwargs) -> None:
29
35
  timestamp: str = EpochTimestamp.now().to_string()
30
36
  try:
@@ -34,15 +40,15 @@ class SimpleLogger:
34
40
  if kwargs:
35
41
  for key, value in kwargs.items():
36
42
  self.buffer.append(f"{key}={value}")
37
- print(f"{end}".join(self.buffer), file=self.redirect)
43
+ self.print(f"{end}".join(self.buffer))
38
44
  except Exception as e:
39
- print(f"[{timestamp}] {level.value}: {msg} - Error: {e}", file=self.redirect)
45
+ self.print(f"[{timestamp}] {level.value}: {msg} - Error: {e}")
40
46
  finally:
41
47
  self.buffer.clear()
42
48
 
43
49
  def log(self, level: LogLevel, msg: object, *args, **kwargs) -> None:
44
50
  """Log a message at the specified level."""
45
- if level.value >= self.min_level.value:
51
+ if level.value >= self.level.value:
46
52
  self._log(level, msg, *args, **kwargs)
47
53
 
48
54
  def verbose(self, msg: object, *args, **kwargs) -> None:
@@ -65,13 +71,22 @@ class SimpleLogger:
65
71
  """Log an error message."""
66
72
  self.log(ERROR, msg, *args, **kwargs)
67
73
 
74
+ def success(self, msg: object, *args, **kwargs) -> None:
75
+ """Log a success message."""
76
+ self.log(SUCCESS, msg, *args, **kwargs)
77
+
78
+ def failure(self, msg: object, *args, **kwargs) -> None:
79
+ """Log a failure message."""
80
+ self.log(FAILURE, msg, *args, **kwargs)
81
+
68
82
 
69
83
  # Example usage:
70
84
  if __name__ == "__main__":
71
- logger = SimpleLogger()
72
-
73
- logger.verbose(msg="This is a verbose message")
74
- logger.debug(msg="This is a debug message")
85
+ logger = SimpleLogger(file=StringIO())
86
+ logger_two = SimpleLogger(level="INFO", file=STDERR)
75
87
  logger.info(msg="This is an info message")
76
- logger.warning(msg="This is a warning message")
77
- logger.error(msg="This is an error message")
88
+ logger_two.info(msg="This is an info message")
89
+
90
+ value = logger.file
91
+ print(value.getvalue()) # Print the captured log messages from StringIO
92
+ # print(logger_two.file.getvalue()) # should throw a typing error since it is not a StringIO
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bear-utils
3
- Version: 0.8.26
3
+ Version: 0.9.0
4
4
  Summary: Various utilities for Bear programmers, including a rich logging utility, a disk cache, and a SQLite database wrapper amongst other things.
5
5
  Author-email: chaz <bright.lid5647@fastmail.com>
6
6
  Requires-Python: >=3.12
7
- Requires-Dist: bear-epoch-time>=1.1.1
7
+ Requires-Dist: bear-epoch-time>=1.1.4
8
8
  Requires-Dist: diskcache<6.0.0,>=5.6.3
9
9
  Requires-Dist: fastapi>=0.116.0
10
10
  Requires-Dist: httpx>=0.28.1
@@ -25,7 +25,7 @@ Provides-Extra: gui
25
25
  Requires-Dist: pyqt6>=6.9.0; extra == 'gui'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
- # Bear Utils v# Bear Utils v0.8.26
28
+ # Bear Utils v# Bear Utils v0.9.0
29
29
 
30
30
  Personal set of tools and utilities for Python projects, focusing on modularity and ease of use. This library includes components for caching, database management, logging, time handling, file operations, CLI prompts, image processing, clipboard interaction, gradient utilities, event systems, and async helpers.
31
31