Cat Feeder  1.0.0
The Cat feeder project
Loading...
Searching...
No Matches
bucket.py
Go to the documentation of this file.
1r"""
2# +==== BEGIN CatFeeder =================+
3# LOGO:
4# ..............(..../\
5# ...............)..(.')
6# ..............(../..)
7# ...............\‍(__)|
8# Inspired by Joan Stark
9# source https://www.asciiart.eu/
10# animals/cats
11# /STOP
12# PROJECT: CatFeeder
13# FILE: bucket.py
14# CREATION DATE: 11-10-2025
15# LAST Modified: 17:56:8 09-01-2026
16# DESCRIPTION:
17# This is the backend server in charge of making the actual website work.
18# /STOP
19# COPYRIGHT: (c) Cat Feeder
20# PURPOSE: File in charge of providing a boiled down interface for interracting with an s3 bucket.
21# // AR
22# +==== END CatFeeder =================+
23"""
24
25from __future__ import annotations
26
27from typing import List, Union, Dict, Any, Optional, cast
28import boto3
29from botocore.client import Config
30from botocore.exceptions import BotoCoreError, ClientError
31from display_tty import Disp, initialise_logger
32from .bucket_class_aliases import S3ObjectLike, S3BucketLike, S3ServiceResourceLike
33from . import bucket_constants as CONST
34from ..core import FinalClass
35
36
37class Bucket(metaclass=FinalClass):
38 """
39 Class to manage interaction with an S3-compatible bucket like MinIO.
40
41 Attributes:
42 disp (Disp): Logger instance for debugging and error reporting.
43 connection (Optional[S3ServiceResourceLike]): Active S3 connection resource.
44 debug (bool): Debug mode flag.
45 success (int): Numeric success code.
46 error (int): Numeric error code.
47 """
48
49 disp: Disp = initialise_logger(__qualname__, False)
50
51 # --- Instance tracker to avoid creating unnecessary duplicate instances ---
52 _initialization_attempted: bool = False
53 _initialization_failed: bool = False
54
55 def __init__(self, error: int = 84, success: int = 0, auto_connect: bool = True, debug: bool = False) -> None:
56 """Initialize the Bucket instance.
57
58 Args:
59 error (int, optional): Numeric error code. Defaults to 84.
60 success (int, optional): Numeric success code. Defaults to 0.
61 auto_connect (bool, optional): If True, automatically connect to S3. Defaults to True.
62 debug (bool, optional): Enable debug logging. Defaults to False.
63 """
64 # ------------------------ The logging function ------------------------
65 self.disp.update_disp_debug(debug)
66 self.disp.log_debug("Initialising...")
67 # -------------------------- Inherited values --------------------------
68 self.debug: bool = debug
69 self.error: int = error
70 self.success: int = success
71 # ----------------------- The connector address -----------------------
72 # boto3 S3 resource when connected
73 self.connection: Optional[S3ServiceResourceLike] = None
74 self._is_connected_is_connected: bool = False
75
76 # Auto-connect if requested (for RuntimeManager compatibility)
77 if auto_connect:
78 self._auto_connect()
79
80 self.disp.log_debug("Initialised")
81
82 def _auto_connect(self) -> None:
83 """Automatically connect to S3 service.
84
85 Called during __init__ when auto_connect=True.
86 Tracks connection attempts to provide better error messages.
87 """
88 Bucket._initialization_attempted = True
89 try:
90 if self.connect() != self.success:
91 Bucket._initialization_failed = True
92 msg = "Failed to connect to S3/MinIO service."
93 self.disp.log_error(msg)
94 # Don't raise here - allow the instance to exist but mark as failed
95 else:
97 except (BotoCoreError, ClientError, ConnectionError, RuntimeError) as e:
98 Bucket._initialization_failed = True
99 self.disp.log_error(f"Connection failed: {e}")
100 # Don't raise here - allow graceful degradation
101
102 def __del__(self) -> None:
103 """Best-effort cleanup invoked when the instance is garbage-collected.
104
105 This releases the S3 connection resource.
106 """
107 if self.connection is not None:
108 try:
109 self.disconnect()
110 except (BotoCoreError, ClientError, ConnectionError, RuntimeError, AttributeError):
111 pass # Suppress errors in destructor
112 self.connection = None
113
114 def _ensure_connected(self) -> None:
115 """Ensure the Bucket instance is connected to S3.
116
117 If connection was never attempted, try to auto-connect.
118 If connection was attempted but failed, try to reconnect once.
119
120 Raises:
121 RuntimeError: If connection has failed and cannot be re-established.
122 """
123 if self._is_connected_is_connected and self.is_connected():
124 return
125
126 if not Bucket._initialization_attempted:
127 self.disp.log_debug(
128 "Attempting lazy connection", "_ensure_connected")
129 self._auto_connect()
131 # Try to reconnect once
132 self.disp.log_debug("Attempting to reconnect", "_ensure_connected")
133 if self.connect() == self.success:
135 Bucket._initialization_failed = False
136 else:
137 raise RuntimeError(
138 "Bucket connection failed. Cannot perform operations without active S3 connection."
139 )
140
141 def connect(self) -> int:
142 """
143 Connect to the S3-compatible service (MinIO, Cellar, AWS S3, etc.).
144
145 Returns:
146 int: success or error code.
147 """
148 try:
149 # Build endpoint URL - ensure it includes protocol scheme
150 endpoint_url = CONST.BUCKET_HOST
151
152 # Add protocol if not present
153 if not endpoint_url.startswith(('http://', 'https://')):
154 endpoint_url = f"http://{endpoint_url}"
155
156 # Add port only if specified
157 if CONST.BUCKET_PORT:
158 endpoint_url = f"{endpoint_url}:{CONST.BUCKET_PORT}"
159
160 # Create S3 service resource
161 resource = boto3.resource(
162 CONST.BUCKET_RESSOURCE_TYPE,
163 endpoint_url=endpoint_url,
164 aws_access_key_id=CONST.BUCKET_USER,
165 aws_secret_access_key=CONST.BUCKET_PASSWORD,
166 config=Config(signature_version=CONST.BUCKET_SIGNATURE_VERSION)
167 )
168 self.connection = cast(S3ServiceResourceLike, resource)
169 # Check connection by listing buckets
170 conn = self.connection
171 assert conn is not None
172 conn.meta.client.list_buckets()
173 self.disp.log_info(
174 "Connection to S3-compatible service successful.")
175 return self.success
176 except (BotoCoreError, ClientError) as e:
177 self.disp.log_error(
178 f"Failed to connect to S3-compatible service: {str(e)}"
179 )
180 return self.error
181
182 def is_connected(self) -> bool:
183 """
184 Check if the connection to the S3-compatible service is active.
185
186 Returns:
187 bool: True if connected, False otherwise.
188 """
189 if self.connection is None:
190 self.disp.log_error("No connection object found.")
191 return False
192
193 try:
194 # Attempt to list buckets as a simple test of the connection
195 self.connection.meta.client.list_buckets()
196 self.disp.log_info("Connection is active.")
197 return True
198 except (BotoCoreError, ClientError, ConnectionError) as e:
199 self.disp.log_error(
200 f"Connection check failed: {str(e)}"
201 )
202 return False
203
204 def disconnect(self) -> int:
205 """
206 Disconnect from the S3-compatible service by setting the connection to None.
207
208 Returns:
209 int: success or error code.
210 """
211 if self.connection is None:
212 self.disp.log_warning(
213 "No active connection to disconnect."
214 )
215 return self.error
216
217 # Setting to None is safe and should not raise; log and return success
218 self.connection = None
219 self.disp.log_info(
220 "Disconnected from the S3-compatible service."
221 )
222 return self.success
223
224 def get_bucket_names(self) -> Union[List[str], int]:
225 """
226 Retrieve a list of all bucket names.
227
228 Returns:
229 Union[List[str], int]: A list of bucket names or error code.
230 """
231 try:
232 self._ensure_connected()
233 if self.connection is None:
234 raise ConnectionError("No connection established.")
235 buckets = [bucket.name for bucket in self.connection.buckets.all()]
236 return buckets
237 except (BotoCoreError, ClientError, ConnectionError) as e:
238 self.disp.log_error(
239 f"Error fetching bucket names: {str(e)}"
240 )
241 return self.error
242
243 def create_bucket(self, bucket_name: str) -> int:
244 """
245 Create a new bucket.
246
247 Args:
248 bucket_name (str): Name of the bucket to create.
249
250 Returns:
251 int: success or error code.
252 """
253 try:
254 self._ensure_connected()
255 if self.connection is None:
256 raise ConnectionError("No connection established.")
257 self.connection.create_bucket(Bucket=bucket_name)
258 self.disp.log_info(
259 f"Bucket '{bucket_name}' created successfully."
260 )
261 return self.success
262 except (BotoCoreError, ClientError, ConnectionError) as e:
263 self.disp.log_error(
264 f"Failed to create bucket '{bucket_name}': {str(e)}"
265 )
266 return self.error
267
268 def upload_file(self, bucket_name: str, file_path: str, key_name: Optional[str] = None) -> int:
269 """
270 Upload a file to the specified bucket.
271
272 Args:
273 bucket_name (str): Name of the target bucket.
274 file_path (str): Path of the file to upload.
275 key_name (Optional[str]): Name to save the file as in the bucket. Defaults to the file path name.
276
277 Returns:
278 int: success or error code.
279 """
280 key_name = key_name or file_path
281 try:
282 self._ensure_connected()
283 if self.connection is None:
284 raise ConnectionError("No connection established.")
285 bucket: S3BucketLike = self.connection.Bucket(
286 bucket_name) # type: ignore[assignment]
287 bucket.upload_file(file_path, key_name)
288 msg = f"File '{file_path}' uploaded to bucket "
289 msg += f"'{bucket_name}' as '{key_name}'."
290 self.disp.log_info(msg)
291 return self.success
292 except (BotoCoreError, ClientError, ConnectionError) as e:
293 msg = f"Failed to upload file '{file_path}' to bucket "
294 msg += f"'{bucket_name}': {str(e)}"
295 self.disp.log_error(msg)
296 return self.error
297
298 def upload_stream(self, bucket_name: str, data: bytes, key_name: Optional[str] = None) -> int:
299 """Upload a file to the specified bucket from a byte stream.
300
301 Args:
302 bucket_name (str): Name of the target bucket.
303 data (bytes): The file content as bytes.
304 key_name (Optional[str]): Name to save the file as in the bucket. Defaults to a generated name if not provided.
305
306 Returns:
307 int: success or error code.
308 """
309 if key_name is None:
310 self.disp.log_error(
311 "key_name must be provided for stream uploads")
312 return self.error
313 try:
314 self._ensure_connected()
315 if self.connection is None:
316 raise ConnectionError("No connection established.")
317 bucket: S3BucketLike = self.connection.Bucket(
318 bucket_name) # type: ignore[assignment]
319 bucket.put_object(Key=key_name, Body=data)
320 msg = f"File stream uploaded to bucket '{bucket_name}' as '{key_name}' ({len(data)} bytes)."
321 self.disp.log_info(msg)
322 return self.success
323 except (BotoCoreError, ClientError, ConnectionError, RuntimeError) as e:
324 msg = f"Failed to upload file stream to bucket '{bucket_name}' as '{key_name}': {str(e)}"
325 self.disp.log_error(msg)
326 return self.error
327
328 def download_file(self, bucket_name: str, key_name: str, destination_path: Optional[str] = None) -> int:
329 """
330 Download a file from the specified bucket.
331
332 Args:
333 bucket_name (str): Name of the target bucket.
334 key_name (str): Name of the file to download.
335 destination_path (Optional[str]): Local path where the file will be saved. Defaults to the key_name if not provided.
336
337 Returns:
338 int: success or error code.
339 """
340 destination_path = destination_path or key_name
341 try:
342 self._ensure_connected()
343 if self.connection is None:
344 raise ConnectionError("No connection established.")
345 bucket: S3BucketLike = self.connection.Bucket(
346 bucket_name) # type: ignore[assignment]
347 bucket.download_file(key_name, destination_path)
348 msg = f"File '{key_name}' downloaded from bucket "
349 msg += f"'{bucket_name}' to '{destination_path}'."
350 self.disp.log_info(msg)
351 return self.success
352 except (BotoCoreError, ClientError, ConnectionError) as e:
353 msg = f"Failed to download file '{key_name}'"
354 msg += f" from bucket '{bucket_name}': {str(e)}"
355 self.disp.log_error(msg)
356 return self.error
357
358 def download_stream(self, bucket_name: str, key_name: str) -> Union[bytes, int]:
359 """Download a file from the specified bucket and return it as a byte stream.
360
361 Args:
362 bucket_name (str): Name of the target bucket.
363 key_name (str): Name of the file to download.
364
365 Returns:
366 Union[bytes, int]: File content as bytes or error code.
367 """
368 try:
369 self._ensure_connected()
370 if self.connection is None:
371 raise ConnectionError("No connection established.")
372 bucket: S3BucketLike = self.connection.Bucket(
373 bucket_name) # type: ignore[assignment]
374 obj: S3ObjectLike = bucket.Object(
375 key_name) # type: ignore[assignment]
376 file_data = obj.get()['Body'].read()
377 msg = f"File '{key_name}' downloaded from bucket "
378 msg += f"'{bucket_name}' as stream ({len(file_data)} bytes)."
379 self.disp.log_info(msg)
380 return file_data
381 except (BotoCoreError, ClientError, ConnectionError, RuntimeError) as e:
382 msg = f"Failed to download file '{key_name}' from bucket "
383 msg += f"'{bucket_name}' as stream: {str(e)}"
384 self.disp.log_error(msg)
385 return self.error
386
387 def delete_file(self, bucket_name: str, key_name: str) -> int:
388 """
389 Delete a file from the specified bucket.
390
391 Args:
392 bucket_name (str): Name of the bucket.
393 key_name (str): Name of the file to delete.
394
395 Returns:
396 int: success or error code.
397 """
398 try:
399 self._ensure_connected()
400 if self.connection is None:
401 raise ConnectionError("No connection established.")
402 bucket: S3BucketLike = self.connection.Bucket(
403 bucket_name) # type: ignore[assignment]
404 bucket.Object(key_name).delete()
405 self.disp.log_info(
406 f"File '{key_name}' deleted from bucket '{bucket_name}'."
407 )
408 return self.success
409 except (BotoCoreError, ClientError, ConnectionError) as e:
410 msg = f"Failed to delete file '{key_name}' from bucket "
411 msg += f"'{bucket_name}': {str(e)}"
412 self.disp.log_error(msg)
413 return self.error
414
415 def delete_bucket(self, bucket_name: str) -> int:
416 """
417 Delete a bucket.
418
419 Args:
420 bucket_name (str): Name of the bucket to delete.
421
422 Returns:
423 int: success or error code.
424 """
425 try:
426 self._ensure_connected()
427 if self.connection is None:
428 raise ConnectionError("No connection established.")
429 bucket: S3BucketLike = self.connection.Bucket(
430 bucket_name) # type: ignore[assignment]
431 bucket.delete()
432 self.disp.log_info(
433 f"Bucket '{bucket_name}' deleted successfully."
434 )
435 return self.success
436 except (BotoCoreError, ClientError, ConnectionError) as e:
437 self.disp.log_error(
438 f"Failed to delete bucket '{bucket_name}': {str(e)}"
439 )
440 return self.error
441
442 def get_bucket_files(self, bucket_name: str) -> Union[List[str], int]:
443 """
444 List all files in the specified bucket.
445
446 Args:
447 bucket_name (str): Name of the bucket.
448
449 Returns:
450 Union[List[str], int]: List of file names or error code.
451 """
452 try:
453 self._ensure_connected()
454 if self.connection is None:
455 raise ConnectionError("No connection established.")
456 files: List[str] = []
457 bucket: S3BucketLike = self.connection.Bucket(
458 bucket_name) # type: ignore[assignment]
459 for obj in bucket.objects.all():
460 files.append(obj.key)
461 return files
462 except (BotoCoreError, ClientError, ConnectionError) as e:
463 msg = f"Failed to retrieve files from bucket '{bucket_name}'"
464 msg += f": {str(e)}"
465 self.disp.log_error(msg)
466 return self.error
467
468 def get_bucket_file(self, bucket_name: str, key_name: str) -> Union[Dict[str, Any], int]:
469 """
470 Get information about a specific file in the bucket.
471
472 Args:
473 bucket_name (str): Name of the bucket.
474 key_name (str): Name of the file.
475
476 Returns:
477 Union[Dict[str, Any], int]: File metadata (path and size) or error code.
478 """
479 try:
480 self._ensure_connected()
481 if self.connection is None:
482 raise ConnectionError("No connection established.")
483 bucket: S3BucketLike = self.connection.Bucket(
484 bucket_name) # type: ignore[assignment]
485 obj: S3ObjectLike = bucket.Object(
486 key_name) # type: ignore[assignment]
487 return {'file_path': key_name, 'file_size': obj.content_length}
488 except (BotoCoreError, ClientError, ConnectionError, RuntimeError) as e:
489 msg = f"Failed to get file '{key_name}' "
490 msg += f"from bucket '{bucket_name}': {str(e)}"
491 self.disp.log_error(msg)
492 return self.error
Union[List[str], int] get_bucket_files(self, str bucket_name)
Definition bucket.py:442
int download_file(self, str bucket_name, str key_name, Optional[str] destination_path=None)
Definition bucket.py:328
int delete_bucket(self, str bucket_name)
Definition bucket.py:415
int delete_file(self, str bucket_name, str key_name)
Definition bucket.py:387
int upload_file(self, str bucket_name, str file_path, Optional[str] key_name=None)
Definition bucket.py:268
Union[bytes, int] download_stream(self, str bucket_name, str key_name)
Definition bucket.py:358
Union[List[str], int] get_bucket_names(self)
Definition bucket.py:224
int upload_stream(self, str bucket_name, bytes data, Optional[str] key_name=None)
Definition bucket.py:298
Union[Dict[str, Any], int] get_bucket_file(self, str bucket_name, str key_name)
Definition bucket.py:468
None __init__(self, int error=84, int success=0, bool auto_connect=True, bool debug=False)
Definition bucket.py:55
int create_bucket(self, str bucket_name)
Definition bucket.py:243
Optional[S3ServiceResourceLike] connection
Definition bucket.py:73