From ca116562fe035c8c969778701ea2307fbc288c7a Mon Sep 17 00:00:00 2001 From: Artsiom Siamashka Date: Wed, 1 Apr 2026 12:54:51 +0200 Subject: [PATCH] Initial --- .dockerignore | 52 +++++++++++++++++++++++++++++++++ .gitignore | 66 +++++++++++++++++++++++++++++++++++++++++ s3_video_storage.py | 71 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 s3_video_storage.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..87188f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,52 @@ +# Git +.git/ +.gitignore + +# Python cache / bytecode +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Build / packaging artifacts +build/ +dist/ +*.egg-info/ +.eggs/ +pip-wheel-metadata/ + +# Test and tooling caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.tox/ +.nox/ +.coverage +.coverage.* +htmlcov/ + +# Notebooks +.ipynb_checkpoints/ + +# Editor and OS files +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Local env and logs +.env +.env.* +*.log +*.pid + +# Optional large local media +*.mp4 +*.mov +*.avi +*.mkv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..297b60a --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +dist/ +downloads/ +.eggs/ +*.egg-info/ +*.egg +pip-wheel-metadata/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Unit test / coverage reports +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ + +# Type checker / linter caches +.mypy_cache/ +.pyre/ +.ruff_cache/ +.pytype/ +.dmypy.json +dmypy.json + +# Jupyter Notebook +.ipynb_checkpoints/ + +# IDE/editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Logs and local runtime files +*.log +*.pid + +# Local environment variables +.env +.env.* + +# Project-specific artifacts +*.mp4 +*.mov +*.avi +*.mkv diff --git a/s3_video_storage.py b/s3_video_storage.py new file mode 100644 index 0000000..95787b8 --- /dev/null +++ b/s3_video_storage.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +import boto3 + + +@dataclass(frozen=True) +class S3Config: + bucket_name: str + region_name: str | None = None + endpoint_url: str | None = None + aws_access_key_id: str | None = None + aws_secret_access_key: str | None = None + aws_session_token: str | None = None + + +class S3VideoStorage: + def __init__(self, s3_config: S3Config | Mapping[str, Any]) -> None: + self.config = self._normalize_config(s3_config) + + client_kwargs: dict[str, Any] = { + "region_name": self.config.region_name, + "endpoint_url": self.config.endpoint_url, + "aws_access_key_id": self.config.aws_access_key_id, + "aws_secret_access_key": self.config.aws_secret_access_key, + "aws_session_token": self.config.aws_session_token, + } + filtered_kwargs = {k: v for k, v in client_kwargs.items() if v is not None} + self._s3_client = boto3.client("s3", **filtered_kwargs) + + def store_file(self, file_path: str | Path) -> str: + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"File does not exist: {path}") + if not path.is_file(): + raise ValueError(f"Path is not a file: {path}") + + now = datetime.now(timezone.utc) + key = f"video_content/{now.year:04d}/{now.month:02d}/{now.day:02d}/{path.name}" + + self._s3_client.upload_file(str(path), self.config.bucket_name, key) + return f"s3://{self.config.bucket_name}/{key}" + + @staticmethod + def _normalize_config(s3_config: S3Config | Mapping[str, Any]) -> S3Config: + if isinstance(s3_config, S3Config): + return s3_config + + bucket_name = s3_config.get("bucket_name") + if not bucket_name: + raise ValueError("s3_config must contain non-empty 'bucket_name'") + + return S3Config( + bucket_name=str(bucket_name), + region_name=_optional_str(s3_config, "region_name"), + endpoint_url=_optional_str(s3_config, "endpoint_url"), + aws_access_key_id=_optional_str(s3_config, "aws_access_key_id"), + aws_secret_access_key=_optional_str(s3_config, "aws_secret_access_key"), + aws_session_token=_optional_str(s3_config, "aws_session_token"), + ) + + +def _optional_str(config: Mapping[str, Any], key: str) -> str | None: + value = config.get(key) + if value is None: + return None + return str(value)