diff --git a/gopro_api/__init__.py b/gopro_api/__init__.py index 68dcbcc..e50aee8 100644 --- a/gopro_api/__init__.py +++ b/gopro_api/__init__.py @@ -1,4 +1,9 @@ -"""Unofficial Python client for the GoPro cloud API (api.gopro.com).""" +"""Unofficial Python client for the GoPro cloud API (``api.gopro.com``). + +Exports sync/async low-level API classes, high-level clients, Pydantic models, +the ``NoVariationsError`` exception, and the ``gopro-api`` CLI entrypoint via +``python -m`` / console script. +""" from gopro_api.api.async_gopro import AsyncGoProAPI from gopro_api.api.gopro import GoProAPI diff --git a/gopro_api/api/__init__.py b/gopro_api/api/__init__.py index a0a150c..dd08c30 100644 --- a/gopro_api/api/__init__.py +++ b/gopro_api/api/__init__.py @@ -1,4 +1,8 @@ -"""Sync and async HTTP clients for api.gopro.com.""" +"""Low-level sync and async HTTP clients for ``https://api.gopro.com``. + +Prefer ``gopro_api.client.GoProClient`` / ``AsyncGoProClient`` for pagination +and downloads unless you need direct control over requests. +""" from .gopro import GoProAPI from .async_gopro import AsyncGoProAPI diff --git a/gopro_api/api/async_gopro.py b/gopro_api/api/async_gopro.py index f9aa8c4..55a28c7 100644 --- a/gopro_api/api/async_gopro.py +++ b/gopro_api/api/async_gopro.py @@ -21,8 +21,10 @@ class AsyncGoProAPI: def __init__(self, access_token: str | None = None, timeout: float = 10.0) -> None: """Create an async client. - ``access_token``: cookie value; defaults to ``GP_ACCESS_TOKEN``. - ``timeout``: total HTTP client timeout in seconds. + Args: + access_token: ``gp_access_token`` cookie value; defaults to + ``gopro_api.config.GP_ACCESS_TOKEN``. + timeout: Total client timeout in seconds for ``aiohttp``. """ self.access_token = access_token or GP_ACCESS_TOKEN self._timeout = aiohttp.ClientTimeout(total=timeout) @@ -30,18 +32,33 @@ def __init__(self, access_token: str | None = None, timeout: float = 10.0) -> No @property def base_url(self) -> str: - """API origin (``https://api.gopro.com``).""" + """HTTPS origin for API requests. + + Returns: + Always ``https://api.gopro.com``. + """ return "https://api.gopro.com" def get_headers(self, accept: str) -> dict[str, str]: - """Build ``Cookie`` and ``Accept`` headers for an API call.""" + """Build headers for a JSON API request. + + Args: + accept: Full ``Accept`` header value (vendor MIME type + version). + + Returns: + Mapping with ``Cookie`` (token) and ``Accept``. + """ return { "Cookie": "gp_access_token=" + self.access_token, "Accept": accept, } async def __aenter__(self) -> "AsyncGoProAPI": - """Open an ``aiohttp.ClientSession`` for the ``async with`` body.""" + """Open an ``aiohttp.ClientSession`` for the ``async with`` body. + + Returns: + ``self`` for use inside the ``async with`` body. + """ self._session = aiohttp.ClientSession( base_url=self.base_url, timeout=self._timeout, @@ -49,13 +66,24 @@ async def __aenter__(self) -> "AsyncGoProAPI": return self async def __aexit__(self, *exc: object) -> None: - """Close the session.""" + """Close the session and clear internal state. + + Args: + *exc: Exception info from the interpreter (ignored). + """ if self._session is not None: await self._session.close() self._session = None def _session_or_raise(self) -> aiohttp.ClientSession: - """Return the active session or raise if not inside ``async with``.""" + """Return the active ``aiohttp.ClientSession``. + + Returns: + The session opened in ``__aenter__``. + + Raises: + RuntimeError: If called before ``__aenter__`` or after ``__aexit__``. + """ session = self._session if session is None: msg = ( @@ -66,7 +94,22 @@ def _session_or_raise(self) -> aiohttp.ClientSession: return session async def download(self, media_id: str) -> GoProMediaDownloadResponse: - """``GET /media/{media_id}/download`` — metadata and CDN URLs for files.""" + """Return download metadata and CDN URLs for one media item. + + Calls ``GET /media/{media_id}/download`` with the GoPro media JSON + vendor MIME type. + + Args: + media_id: Cloud library identifier for the media item. + + Returns: + Parsed response (filenames, variations, embedded files, CDN URLs). + + Raises: + RuntimeError: If used outside ``async with AsyncGoProAPI() as api``. + aiohttp.ClientResponseError: When ``raise_for_status`` fails. + pydantic.ValidationError: If the JSON body does not match the model. + """ headers = self.get_headers("application/vnd.gopro.jk.media+json; version=2.0.0") session = self._session_or_raise() async with session.get( @@ -78,7 +121,22 @@ async def download(self, media_id: str) -> GoProMediaDownloadResponse: return GoProMediaDownloadResponse.model_validate_json(body) async def search(self, params: GoProMediaSearchParams) -> GoProMediaSearchResponse: - """``GET /media/search`` using ``params.model_dump()`` as query string.""" + """Search media in the cloud library with structured query parameters. + + Calls ``GET /media/search``; ``params.model_dump()`` is sent as the query + string after serialization. + + Args: + params: Search filters (capture range, pagination, fields, etc.). + + Returns: + Paginated search results and embedded media rows. + + Raises: + RuntimeError: If used outside ``async with AsyncGoProAPI() as api``. + aiohttp.ClientResponseError: When ``raise_for_status`` fails. + pydantic.ValidationError: If the JSON body does not match the model. + """ headers = self.get_headers( "application/vnd.gopro.jk.media.search+json; version=2.0.0", ) diff --git a/gopro_api/api/gopro.py b/gopro_api/api/gopro.py index ed59636..58aa92b 100644 --- a/gopro_api/api/gopro.py +++ b/gopro_api/api/gopro.py @@ -21,8 +21,10 @@ class GoProAPI: def __init__(self, access_token: str | None = None, timeout: float = 10.0) -> None: """Create a sync client. - ``access_token``: cookie value; defaults to ``GP_ACCESS_TOKEN``. - ``timeout``: per-request timeout in seconds. + Args: + access_token: ``gp_access_token`` cookie value; defaults to + ``gopro_api.config.GP_ACCESS_TOKEN``. + timeout: Per-request timeout in seconds passed to ``requests``. """ self.access_token = access_token or GP_ACCESS_TOKEN self._timeout = timeout @@ -30,36 +32,77 @@ def __init__(self, access_token: str | None = None, timeout: float = 10.0) -> No @property def base_url(self) -> str: - """API origin (``https://api.gopro.com``).""" + """HTTPS origin for API requests. + + Returns: + Always ``https://api.gopro.com``. + """ return "https://api.gopro.com" def get_headers(self, accept: str) -> dict[str, str]: - """Build ``Cookie`` and ``Accept`` headers for an API call.""" + """Build headers for a JSON API request. + + Args: + accept: Full ``Accept`` header value (vendor MIME type + version). + + Returns: + Mapping with ``Cookie`` (token) and ``Accept``. + """ return { "Cookie": "gp_access_token=" + self.access_token, "Accept": accept, } def __enter__(self) -> "GoProAPI": - """Open a ``requests.Session`` for the duration of the ``with`` block.""" + """Open a ``requests.Session`` for the duration of the ``with`` block. + + Returns: + ``self`` for use inside the ``with`` body. + """ self._session = requests.Session() return self def __exit__(self, *exc: object) -> None: - """Close the session.""" + """Close the session and clear internal state. + + Args: + *exc: Exception info from the interpreter (ignored). + """ if self._session is not None: self._session.close() self._session = None def _session_or_raise(self) -> requests.Session: - """Return the active session or raise if used outside a context manager.""" + """Return the active ``requests.Session``. + + Returns: + The session opened in ``__enter__``. + + Raises: + RuntimeError: If called before ``__enter__`` or after ``__exit__``. + """ if self._session is None: msg = "Use GoProAPI as a context manager: with GoProAPI() as api: ..." raise RuntimeError(msg) return self._session def download(self, media_id: str) -> GoProMediaDownloadResponse: - """``GET /media/{media_id}/download`` — metadata and CDN URLs for files.""" + """Return download metadata and CDN URLs for one media item. + + Calls ``GET /media/{media_id}/download`` with the GoPro media JSON + vendor MIME type. + + Args: + media_id: Cloud library identifier for the media item. + + Returns: + Parsed response (filenames, variations, embedded files, CDN URLs). + + Raises: + RuntimeError: If used outside ``with GoProAPI() as api``. + requests.HTTPError: When the response status is not successful. + pydantic.ValidationError: If the JSON body does not match the model. + """ headers = self.get_headers("application/vnd.gopro.jk.media+json; version=2.0.0") session = self._session_or_raise() response = session.get( @@ -71,7 +114,22 @@ def download(self, media_id: str) -> GoProMediaDownloadResponse: return GoProMediaDownloadResponse.model_validate_json(response.text) def search(self, params: GoProMediaSearchParams) -> GoProMediaSearchResponse: - """``GET /media/search`` using ``params.model_dump()`` as query string.""" + """Search media in the cloud library with structured query parameters. + + Calls ``GET /media/search``; ``params.model_dump()`` is sent as the query + string after serialization. + + Args: + params: Search filters (capture range, pagination, fields, etc.). + + Returns: + Paginated search results and embedded media rows. + + Raises: + RuntimeError: If used outside ``with GoProAPI() as api``. + requests.HTTPError: When the response status is not successful. + pydantic.ValidationError: If the JSON body does not match the model. + """ headers = self.get_headers( "application/vnd.gopro.jk.media.search+json; version=2.0.0", ) diff --git a/gopro_api/api/models.py b/gopro_api/api/models.py index 5e8bf81..33071ca 100644 --- a/gopro_api/api/models.py +++ b/gopro_api/api/models.py @@ -42,14 +42,26 @@ class CapturedRange(BaseModel): - """Capture window for search; serialized to the API ``captured_range`` string.""" + """Inclusive capture date window used in search queries. + + Serialized to a single ``captured_range`` query string with fixed + ``T00:00:00.000Z`` suffixes, as required by the cloud API. + + Attributes: + start: Range start (date portion used in the wire format). + end: Range end (date portion used in the wire format). + """ start: datetime end: datetime @model_serializer def _serialize_captured_range(self) -> str: - """Emit the ``captured_range`` query fragment expected by the API.""" + """Serialize this range for the ``captured_range`` query parameter. + + Returns: + Comma-separated ISO date pair with ``Z`` UTC suffixes. + """ return ( f"{self.start.isoformat()}T00:00:00.000Z," f"{self.end.isoformat()}T00:00:00.000Z" @@ -57,7 +69,19 @@ def _serialize_captured_range(self) -> str: class GoProMediaSearchParams(BaseModel): - """Query parameters for ``GET /media/search`` (lists as comma-separated values).""" + """Query body for ``GET /media/search``. + + List fields are serialized to comma-separated strings in the query string. + Defaults match typical Quik / cloud library expectations. + + Attributes: + processing_states: Allowed processing states filter. + fields: Columns to request for each media row. + type: Media type filter. + captured_range: Capture time window. + page: 1-based page index. + per_page: Page size. + """ processing_states: List[str] = DEFAULT_PROCESSING_STATES fields: List[str] = DEFAULT_FIELDS @@ -71,12 +95,36 @@ class GoProMediaSearchParams(BaseModel): @field_serializer("processing_states", "fields", "type") def _serialize_csv_lists(self, value: List[str]) -> str: - """Join list fields into one comma-separated string for the query.""" + """Join list fields into one comma-separated string for the query. + + Args: + value: String sequence to join. + + Returns: + Single CSV fragment suitable for the query string. + """ return ",".join(value) class GoProMediaSearchItem(BaseModel): - """Single item in ``_embedded.media`` from a media search response.""" + """One media row from ``GET /media/search``. + + Unknown JSON keys are retained via ``extra="allow"`` for forward compatibility. + + Attributes: + id: Cloud media identifier (used with ``download``). + type: Media kind (video, photo, burst, etc.). + captured_at: Capture timestamp when provided. + filename: Primary filename when provided. + file_extension: Extension without dot when provided. + file_size: Size in bytes when provided. + item_count: Parts in a burst/set when provided. + width: Pixel width when provided. + height: Pixel height when provided. + gopro_user_id: Owning user id from the API. + source_gumi: Source identifier from the API. + source_mgumi: Optional secondary source id. + """ model_config = ConfigDict(extra="allow") @@ -95,7 +143,12 @@ class GoProMediaSearchItem(BaseModel): class GoProMediaSearchEmbedded(BaseModel): - """``_embedded`` object on a media search response.""" + """``_embedded`` block for a search response (``_embedded`` in JSON). + + Attributes: + media: Result rows for the current page. + errors: Non-fatal API warnings or error objects. + """ model_config = ConfigDict(extra="allow") @@ -104,7 +157,14 @@ class GoProMediaSearchEmbedded(BaseModel): class GoProMediaSearchPages(BaseModel): - """Pagination block ``_pages`` on a media search response.""" + """Pagination metadata (``_pages`` in JSON). + + Attributes: + current_page: Active 1-based page. + per_page: Requested page size. + total_items: Total rows matching the query. + total_pages: Total pages available. + """ current_page: int per_page: int @@ -113,7 +173,12 @@ class GoProMediaSearchPages(BaseModel): class GoProMediaSearchResponse(BaseModel): - """Top-level JSON body from ``GET /media/search``.""" + """Top-level JSON body from ``GET /media/search``. + + Attributes: + embedded: Media rows and errors (alias ``_embedded``). + pages: Pagination (alias ``_pages``). + """ model_config = ConfigDict(populate_by_name=True) @@ -122,7 +187,18 @@ class GoProMediaSearchResponse(BaseModel): class GoProMediaDownloadFile(BaseModel): - """One downloadable file under ``_embedded.files`` from download metadata.""" + """Non-video or burst member file with a CDN URL. + + Attributes: + url: HTTPS URL for this part. + head: API-specific head token. + camera_position: Camera slot label from the API. + item_number: Part index within the set. + width: Pixel width. + height: Pixel height. + orientation: EXIF-style orientation integer. + available: Whether the asset is listed as fetchable. + """ model_config = ConfigDict(extra="allow") @@ -137,7 +213,18 @@ class GoProMediaDownloadFile(BaseModel): class GoProMediaDownloadVariation(BaseModel): - """Rendered size / quality variant in ``_embedded.variations``.""" + """Video rendition (resolution / quality) with a CDN URL. + + Attributes: + url: HTTPS URL for this rendition. + head: API-specific head token. + width: Pixel width. + height: Pixel height. + label: Human-readable label from the API. + type: Rendition type string from the API. + quality: Quality bucket from the API. + available: Whether the rendition is listed as fetchable. + """ model_config = ConfigDict(extra="allow") @@ -152,7 +239,16 @@ class GoProMediaDownloadVariation(BaseModel): class GoProMediaDownloadSidecarFile(BaseModel): - """Sidecar asset (e.g. zip) in ``_embedded.sidecar_files``.""" + """Auxiliary asset such as a ZIP sidecar. + + Attributes: + url: HTTPS URL when available. + head: API-specific head token. + label: Display label. + type: Asset type string from the API. + fps: Frames per second when applicable. + available: Whether the asset is listed as fetchable. + """ model_config = ConfigDict(extra="allow") @@ -165,7 +261,14 @@ class GoProMediaDownloadSidecarFile(BaseModel): class GoProMediaDownloadEmbedded(BaseModel): - """``_embedded`` on a media download metadata response.""" + """Payload nested under ``_embedded`` for download metadata. + + Attributes: + files: Non-video / multi-part files. + variations: Video renditions. + sprites: Sprite sheet metadata (structure varies). + sidecar_files: Additional downloadable bundles. + """ model_config = ConfigDict(extra="allow") @@ -176,7 +279,12 @@ class GoProMediaDownloadEmbedded(BaseModel): class GoProMediaDownloadResponse(BaseModel): - """Top-level JSON body from ``GET /media/{id}/download``.""" + """Top-level JSON body from ``GET /media/{id}/download``. + + Attributes: + filename: Primary media filename from the API. + embedded: Nested files, variations, and sidecars (alias ``_embedded``). + """ model_config = ConfigDict(populate_by_name=True) diff --git a/gopro_api/cli.py b/gopro_api/cli.py index d559496..873886f 100644 --- a/gopro_api/cli.py +++ b/gopro_api/cli.py @@ -33,7 +33,17 @@ def _version() -> str: def _parse_dt(raw: str) -> datetime: - """Accept YYYY-MM-DD or ISO datetime.""" + """Parse a CLI date or datetime string. + + Args: + raw: ``YYYY-MM-DD`` or full ISO 8601 string (``Z`` normalized to offset). + + Returns: + Parsed naive or aware ``datetime`` as produced by ``fromisoformat``. + + Raises: + ValueError: If the string is not a valid ISO date/datetime. + """ raw = raw.strip() if len(raw) == 10 and raw[4] == "-" and raw[7] == "-": return datetime.fromisoformat(raw) @@ -41,6 +51,17 @@ def _parse_dt(raw: str) -> datetime: def _positive_int(raw: str) -> int: + """Argparse type: strictly positive integer. + + Args: + raw: Decimal digits only. + + Returns: + Parsed integer ``>= 1``. + + Raises: + argparse.ArgumentTypeError: If ``raw`` is not a positive integer. + """ value = int(raw) if value <= 0: raise argparse.ArgumentTypeError("must be a positive integer") @@ -48,6 +69,11 @@ def _positive_int(raw: str) -> int: def _require_token() -> None: + """Ensure ``GP_ACCESS_TOKEN`` is configured. + + Raises: + SystemExit: With code ``2`` if the token is missing. + """ if not GP_ACCESS_TOKEN: sys.stderr.write( "error: GP_ACCESS_TOKEN is not set. " @@ -72,10 +98,10 @@ def _print_search_plain_page( *, print_header: bool = True, ) -> None: - p = page_result.pages + pages = page_result.pages print( - f"# _pages: current_page={p.current_page} per_page={p.per_page} " - f"total_items={p.total_items} total_pages={p.total_pages}", + f"# _pages: current_page={pages.current_page} per_page={pages.per_page} " + f"total_items={pages.total_items} total_pages={pages.total_pages}", ) if page_result.embedded.errors: for err in page_result.embedded.errors: @@ -90,25 +116,33 @@ def _print_search_plain_page( class CliSubcommand(ABC): - """One subcommand: its parser arguments and async execution.""" + """Abstract CLI subcommand (parser wiring + async runner).""" name: str help: str @abstractmethod def add_arguments(self, parser: argparse.ArgumentParser) -> None: - """Configure the subparser for this command.""" + """Attach arguments to this subcommand's ``ArgumentParser``. + + Args: + parser: Subparser instance from ``add_subparsers``. + """ @abstractmethod async def run(self, args: argparse.Namespace) -> None: - """Execute the command. + """Execute the subcommand. - ``args`` includes parent options (for example ``timeout``). + Args: + args: Parsed namespace including global flags such as ``timeout``. + + Raises: + SystemExit: For user-facing fatal errors (for example missing token). """ class SearchCommand(CliSubcommand): - """``search`` — list media rows in a capture date range.""" + """``search`` — list cloud media in a capture window (plain TSV or JSON).""" name = "search" help = ( @@ -152,6 +186,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: ) async def run(self, args: argparse.Namespace) -> None: + """Run search against the cloud API and print results.""" _require_token() start = _parse_dt(args.start) end = _parse_dt(args.end) @@ -195,7 +230,7 @@ async def run(self, args: argparse.Namespace) -> None: class InfoCommand(CliSubcommand): - """``info`` — show download metadata for one media id.""" + """``info`` — print download metadata (URLs and sizes) for one media id.""" name = "info" help = "Show download metadata (URLs, sizes) for one media id" @@ -209,6 +244,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: ) async def run(self, args: argparse.Namespace) -> None: + """Fetch and display download metadata for ``args.media_id``.""" _require_token() async with AsyncGoProClient(timeout=args.timeout) as client: meta = await client.download(args.media_id) @@ -234,7 +270,7 @@ async def run(self, args: argparse.Namespace) -> None: class PullCommand(CliSubcommand): - """``pull`` — download files for one media id.""" + """``pull`` — download resolved assets for one media id into a directory.""" name = "pull" help = "Download files from a media id" @@ -264,6 +300,11 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: ) async def run(self, args: argparse.Namespace) -> None: + """Download all resolved files for ``args.media_id`` into ``args.destination``. + + Raises: + SystemExit: Code ``2`` if the token is missing or no video variations exist. + """ _require_token() async with AsyncGoProClient(timeout=args.timeout) as client: meta = await client.download(args.media_id) @@ -290,13 +331,22 @@ async def run(self, args: argparse.Namespace) -> None: class CliBuilder: # pylint: disable=too-few-public-methods - """Assembles the root parser and one subparser per registered command.""" + """Build ``argparse`` CLI from a sequence of ``CliSubcommand`` implementations.""" def __init__(self, commands: Sequence[CliSubcommand]) -> None: + """Store commands to expose as subparsers. + + Args: + commands: Non-empty sequence of subcommand instances. + """ self._commands = list(commands) def build(self) -> argparse.ArgumentParser: - """Return the root parser with global options and subcommands.""" + """Construct the root parser with globals and subcommands. + + Returns: + Configured ``ArgumentParser`` (not yet ``parse_args``). + """ parser = argparse.ArgumentParser( prog="gopro-api", description="CLI for the unofficial GoPro cloud API (api.gopro.com).", @@ -323,7 +373,14 @@ def build(self) -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> None: - """Parse CLI arguments and dispatch to the selected subcommand handler.""" + """CLI entrypoint: parse ``argv`` and run the selected async subcommand. + + Args: + argv: Argument list (defaults to ``sys.argv[1:]`` when ``None``). + + Raises: + SystemExit: On argparse errors or explicit ``SystemExit`` from commands. + """ builder = CliBuilder( [ SearchCommand(), diff --git a/gopro_api/client.py b/gopro_api/client.py index b52594f..89ff86d 100644 --- a/gopro_api/client.py +++ b/gopro_api/client.py @@ -34,16 +34,8 @@ class GoProClient: """High-level sync client for GoPro cloud media. Wraps ``GoProAPI`` via composition and adds search pagination, asset - selection, and file download helpers. Use as a context manager; the + selection, and file download helpers. Use as a context manager; the underlying HTTP session is opened and closed for you. - - Args: - access_token: Cookie value; defaults to ``GP_ACCESS_TOKEN``. - timeout: Per-request HTTP timeout in seconds. - page_size: Items per search page for ``iter_nonempty_search_pages``. - max_items: Upper bound on items returned from ``list_media_items``. - prefer_height: Target height in pixels for variation scoring. - prefer_width: Target width in pixels for variation scoring. """ def __init__( @@ -56,6 +48,17 @@ def __init__( prefer_height: int | None = None, prefer_width: int | None = None, ) -> None: + """Create a sync high-level client. + + Args: + access_token: ``gp_access_token`` cookie value; defaults to + ``gopro_api.config.GP_ACCESS_TOKEN``. + timeout: Per-request HTTP timeout in seconds (API and CDN fetches). + page_size: Default page size for ``iter_nonempty_search_pages``. + max_items: Maximum rows returned by ``list_media_items``. + prefer_height: Preferred video height in pixels for ``get_download_url``. + prefer_width: Preferred video width in pixels for ``get_download_url``. + """ self._api = GoProAPI(access_token=access_token, timeout=timeout) self._timeout = timeout self.page_size = page_size @@ -64,10 +67,16 @@ def __init__( self.prefer_width = prefer_width def __enter__(self) -> "GoProClient": + """Enter the underlying ``GoProAPI`` context. + + Returns: + ``self``. + """ self._api.__enter__() return self def __exit__(self, *exc: object) -> None: + """Exit the underlying ``GoProAPI`` context.""" self._api.__exit__(*exc) # ------------------------------------------------------------------ @@ -75,11 +84,35 @@ def __exit__(self, *exc: object) -> None: # ------------------------------------------------------------------ def search(self, params: GoProMediaSearchParams) -> GoProMediaSearchResponse: - """Proxy to ``GoProAPI.search``.""" + """Run a single media search request. + + Args: + params: Query parameters for ``GET /media/search``. + + Returns: + Parsed search response. + + Raises: + RuntimeError: If used outside ``with GoProClient()``. + requests.HTTPError: When the HTTP status is not successful. + pydantic.ValidationError: If the JSON body does not match the model. + """ return self._api.search(params) def download(self, media_id: str) -> GoProMediaDownloadResponse: - """Proxy to ``GoProAPI.download``.""" + """Fetch download metadata for one media id. + + Args: + media_id: Cloud library identifier. + + Returns: + Parsed download metadata response. + + Raises: + RuntimeError: If used outside ``with GoProClient()``. + requests.HTTPError: When the HTTP status is not successful. + pydantic.ValidationError: If the JSON body does not match the model. + """ return self._api.download(media_id) # ------------------------------------------------------------------ @@ -97,10 +130,13 @@ def iter_nonempty_search_pages( """Yield search result pages until one returns an empty ``_embedded.media``. Args: - start_date: Capture range start. + start_date: Capture range start (inclusive semantics per API). end_date: Capture range end. per_page: Items per page; defaults to ``self.page_size``. start_page: First page number to request (1-indexed). + + Yields: + Each non-empty ``GoProMediaSearchResponse`` page. """ page = start_page size = per_page if per_page is not None else self.page_size @@ -119,7 +155,23 @@ def iter_nonempty_search_pages( def list_media_items( self, start_date: datetime, end_date: datetime ) -> list[GoProMediaSearchItem]: - """Return up to ``max_items`` media items in the capture window.""" + """Collect media rows across pages up to ``max_items``. + + Args: + start_date: Capture range start. + end_date: Capture range end. + + Returns: + Up to ``self.max_items`` ``GoProMediaSearchItem`` instances. + + Raises: + RuntimeError: If used outside ``with GoProClient()`` on any underlying + ``search`` call. + requests.HTTPError: When any underlying ``search`` HTTP status is not + successful. + pydantic.ValidationError: If any underlying ``search`` JSON body does not + match the model. + """ all_media: list[GoProMediaSearchItem] = [] for page_result in self.iter_nonempty_search_pages(start_date, end_date): all_media.extend(page_result.embedded.media) @@ -130,9 +182,22 @@ def list_media_items( def get_download_url( self, media_items: list[GoProMediaSearchItem] ) -> dict[str, DownloadAsset]: - """Resolve download assets for each item in ``media_items``. + """Resolve download assets for each search row. - Returns a merged ``filename → asset`` mapping for all items. + Args: + media_items: One or more media rows (typically from search). + + Returns: + Merged mapping of output filename to file or variation metadata. + + Raises: + NoVariationsError: For video items with no variations. + RuntimeError: If used outside ``with GoProClient()`` on any underlying + ``download`` call. + requests.HTTPError: When any underlying ``download`` HTTP status is not + successful. + pydantic.ValidationError: If any underlying ``download`` JSON body does not + match the model. """ assets: dict[str, DownloadAsset] = {} for item in media_items: @@ -147,35 +212,34 @@ def get_download_url( return assets def download_url_to_path(self, url: str, dest_path: str) -> None: - """Fetch ``url`` and write bytes to ``dest_path``. + """Download a CDN URL to a local path. + + Uses a one-shot ``requests.get`` because CDN hosts differ from + ``api.gopro.com``. + + Args: + url: Fully qualified HTTPS URL from download metadata. + dest_path: Filesystem path for the response body. - CDN URLs are on a different host from ``api.gopro.com`` so a dedicated - one-shot ``requests.get`` is used rather than the API session. + Raises: + requests.HTTPError: When the HTTP status is not successful. + OSError: If the destination cannot be written. """ response = requests.get(url, timeout=self._timeout) response.raise_for_status() dest_dir = os.path.dirname(dest_path) if dest_dir: os.makedirs(dest_dir, exist_ok=True) - with open(dest_path, "wb") as fh: - fh.write(response.content) + with open(dest_path, "wb") as out_file: + out_file.write(response.content) class AsyncGoProClient: """High-level async client for GoPro cloud media. Wraps ``AsyncGoProAPI`` via composition and mirrors ``GoProClient`` using - ``async/await`` and ``aiohttp`` for all I/O. Use as an async context - manager; the underlying ``aiohttp.ClientSession`` is opened and closed - for you. - - Args: - access_token: Cookie value; defaults to ``GP_ACCESS_TOKEN``. - timeout: Total ``aiohttp`` client timeout in seconds. - page_size: Items per search page. - max_items: Upper bound on items returned from ``list_media_items``. - prefer_height: Target height in pixels for variation scoring. - prefer_width: Target width in pixels for variation scoring. + ``async/await`` and ``aiohttp``. Use as an async context manager; the + underlying ``aiohttp.ClientSession`` is opened and closed for you. """ def __init__( @@ -188,6 +252,17 @@ def __init__( prefer_height: int | None = None, prefer_width: int | None = None, ) -> None: + """Create an async high-level client. + + Args: + access_token: ``gp_access_token`` cookie value; defaults to + ``gopro_api.config.GP_ACCESS_TOKEN``. + timeout: Total ``aiohttp`` client timeout in seconds. + page_size: Default page size for ``iter_nonempty_search_pages``. + max_items: Maximum rows returned by ``list_media_items``. + prefer_height: Preferred video height in pixels for ``get_download_url``. + prefer_width: Preferred video width in pixels for ``get_download_url``. + """ self._api = AsyncGoProAPI(access_token=access_token, timeout=timeout) self.page_size = page_size self.max_items = max_items @@ -195,10 +270,16 @@ def __init__( self.prefer_width = prefer_width async def __aenter__(self) -> "AsyncGoProClient": + """Enter the underlying ``AsyncGoProAPI`` context. + + Returns: + ``self``. + """ await self._api.__aenter__() return self async def __aexit__(self, *exc: object) -> None: + """Exit the underlying ``AsyncGoProAPI`` context.""" await self._api.__aexit__(*exc) # ------------------------------------------------------------------ @@ -206,11 +287,35 @@ async def __aexit__(self, *exc: object) -> None: # ------------------------------------------------------------------ async def search(self, params: GoProMediaSearchParams) -> GoProMediaSearchResponse: - """Proxy to ``AsyncGoProAPI.search``.""" + """Run a single media search request. + + Args: + params: Query parameters for ``GET /media/search``. + + Returns: + Parsed search response. + + Raises: + RuntimeError: If used outside ``async with AsyncGoProClient()``. + aiohttp.ClientResponseError: When ``raise_for_status`` fails. + pydantic.ValidationError: If the JSON body does not match the model. + """ return await self._api.search(params) async def download(self, media_id: str) -> GoProMediaDownloadResponse: - """Proxy to ``AsyncGoProAPI.download``.""" + """Fetch download metadata for one media id. + + Args: + media_id: Cloud library identifier. + + Returns: + Parsed download metadata response. + + Raises: + RuntimeError: If used outside ``async with AsyncGoProClient()``. + aiohttp.ClientResponseError: When ``raise_for_status`` fails. + pydantic.ValidationError: If the JSON body does not match the model. + """ return await self._api.download(media_id) # ------------------------------------------------------------------ @@ -228,10 +333,13 @@ async def iter_nonempty_search_pages( """Yield search pages until one returns an empty ``_embedded.media``. Args: - start_date: Capture range start. + start_date: Capture range start (inclusive semantics per API). end_date: Capture range end. per_page: Items per page; defaults to ``self.page_size``. start_page: First page number to request (1-indexed). + + Yields: + Each non-empty ``GoProMediaSearchResponse`` page. """ page = start_page size = per_page if per_page is not None else self.page_size @@ -250,7 +358,23 @@ async def iter_nonempty_search_pages( async def list_media_items( self, start_date: datetime, end_date: datetime ) -> list[GoProMediaSearchItem]: - """Return up to ``max_items`` media items in the capture window.""" + """Collect media rows across pages up to ``max_items``. + + Args: + start_date: Capture range start. + end_date: Capture range end. + + Returns: + Up to ``self.max_items`` ``GoProMediaSearchItem`` instances. + + Raises: + RuntimeError: If used outside ``async with AsyncGoProClient()`` on any + underlying ``search`` call. + aiohttp.ClientResponseError: When any underlying ``search`` raises for + status. + pydantic.ValidationError: If any underlying ``search`` JSON body does not + match the model. + """ all_media: list[GoProMediaSearchItem] = [] async for page_result in self.iter_nonempty_search_pages(start_date, end_date): all_media.extend(page_result.embedded.media) @@ -261,9 +385,22 @@ async def list_media_items( async def get_download_url( self, media_items: list[GoProMediaSearchItem] ) -> dict[str, DownloadAsset]: - """Resolve download assets for all items in parallel via ``asyncio.gather``. + """Resolve download assets for each search row in parallel. - Returns a merged ``filename → asset`` mapping for all items. + Args: + media_items: One or more media rows (typically from search). + + Returns: + Merged mapping of output filename to file or variation metadata. + + Raises: + NoVariationsError: For video items with no variations. + RuntimeError: If used outside ``async with AsyncGoProClient()`` on any + underlying ``download`` call. + aiohttp.ClientResponseError: When any underlying ``download`` raises for + status. + pydantic.ValidationError: If any underlying ``download`` JSON body does not + match the model. """ results: list[GoProMediaDownloadResponse] = await asyncio.gather( *(self._api.download(item.id) for item in media_items) @@ -280,13 +417,19 @@ async def get_download_url( return assets async def download_url_to_path(self, url: str, dest_path: str) -> None: - """Fetch ``url`` and write bytes to ``dest_path``. + """Download a CDN URL to a local path. + + Opens a dedicated ``aiohttp.ClientSession`` without ``base_url`` so CDN + hosts work. The body is read fully into memory, then written via + ``asyncio.to_thread``. + + Args: + url: Fully qualified HTTPS URL from download metadata. + dest_path: Filesystem path for the response body. - Always opens a fresh ``aiohttp.ClientSession`` (without a base URL) so - that CDN URLs on domains other than ``api.gopro.com`` are handled - correctly. The full response body is buffered in memory before the - file write is offloaded to a thread via ``asyncio.to_thread``, which is - acceptable for typical GoPro file sizes. + Raises: + aiohttp.ClientResponseError: When ``raise_for_status`` fails. + OSError: If the destination cannot be written. """ async with aiohttp.ClientSession() as session: async with session.get(url) as resp: diff --git a/gopro_api/config.py b/gopro_api/config.py index 091c8f8..1280699 100644 --- a/gopro_api/config.py +++ b/gopro_api/config.py @@ -6,10 +6,14 @@ class Settings(BaseSettings): - """Application settings resolved from environment variables and ``.env``. + """Application settings from the process environment and optional ``.env`` file. + + Values are read at instantiation; use ``gopro_api.config.settings`` or the + ``GP_ACCESS_TOKEN`` alias for the token used by API clients and the CLI. Attributes: - gp_access_token: GoPro cloud access token (env var ``GP_ACCESS_TOKEN``). + gp_access_token: GoPro cloud cookie value. Environment variable: + ``GP_ACCESS_TOKEN``. """ model_config = SettingsConfigDict( diff --git a/gopro_api/exceptions.py b/gopro_api/exceptions.py index 69b229b..3eda2e3 100644 --- a/gopro_api/exceptions.py +++ b/gopro_api/exceptions.py @@ -6,4 +6,8 @@ class NoVariationsError(Exception): - """Raised when no video variations are available for a media item.""" + """Raised when video download metadata lists zero renditions. + + Typical cause: ``_embedded.variations`` is empty while the media filename + is treated as video (for example ``.mp4``). + """ diff --git a/gopro_api/utils.py b/gopro_api/utils.py index aae71c3..f3dab11 100644 --- a/gopro_api/utils.py +++ b/gopro_api/utils.py @@ -22,7 +22,14 @@ def is_video_filename(filename: str) -> bool: - """Return True if ``filename`` has a ``.mp4`` extension (case-insensitive).""" + """Return whether the filename looks like MP4 video. + + Args: + filename: Basename or path ending (extension is checked case-insensitively). + + Returns: + ``True`` if the suffix is ``.mp4``. + """ parts = filename.rsplit(".", 1) return len(parts) == 2 and parts[1].lower() == "mp4" @@ -44,28 +51,42 @@ def select_video_variation( target_height: Desired height in pixels, or ``None``. target_width: Desired width in pixels, or ``None``. + Returns: + The selected ``GoProMediaDownloadVariation``. + Raises: NoVariationsError: If ``variations`` is empty. """ if not variations: raise NoVariationsError("API returned no video variations for this media id.") if target_height is None and target_width is None: - return max(variations, key=lambda v: v.height) + return max(variations, key=lambda variation: variation.height) - def score(v: GoProMediaDownloadVariation) -> int: - dh = 0 if target_height is None else (v.height - target_height) ** 2 - dw = 0 if target_width is None else (v.width - target_width) ** 2 - return dh + dw + def score(variation: GoProMediaDownloadVariation) -> int: + height_delta_sq = ( + 0 if target_height is None else (variation.height - target_height) ** 2 + ) + width_delta_sq = ( + 0 if target_width is None else (variation.width - target_width) ** 2 + ) + return height_delta_sq + width_delta_sq - best_score = min(score(v) for v in variations) - tied = [v for v in variations if score(v) == best_score] - return max(tied, key=lambda v: (v.height, v.width)) + best_score = min(score(variation) for variation in variations) + tied = [variation for variation in variations if score(variation) == best_score] + return max(tied, key=lambda variation: (variation.height, variation.width)) def get_file_name(root_name: str, item_number: int) -> str: """Build a part filename by inserting a zero-padded index before the extension. Example: ``get_file_name("GX010001.MP4", 2)`` → ``"GX010001002.MP4"``. + + Args: + root_name: Original media filename including extension. + item_number: Non-negative part index (three-digit zero padding). + + Returns: + Derived filename string. """ media_name, _, file_format = root_name.rpartition(".") return f"{media_name}{str(item_number).zfill(3)}.{file_format}" @@ -83,6 +104,14 @@ def pull_assets_for_response( Non-video: returns every file in ``_embedded.files`` in enumeration order (no ``available`` filtering, preserving CLI behaviour for burst sets). + Args: + result: Parsed download-metadata response for one media id. + target_height: Optional preferred video height for variation scoring. + target_width: Optional preferred video width for variation scoring. + + Returns: + Mapping of local filename to downloadable file or variation row. + Raises: NoVariationsError: For video media when no variations are present. """ @@ -99,6 +128,14 @@ def pull_assets_for_response( def write_bytes(path: str, data: bytes) -> None: - """Write ``data`` to ``path`` (helper for ``asyncio.to_thread``).""" - with open(path, "wb") as fh: - fh.write(data) + """Write binary data to a path (blocking I/O). + + Args: + path: Destination file path. + data: Raw bytes to persist. + + Raises: + OSError: If the file cannot be opened or written. + """ + with open(path, "wb") as out_file: + out_file.write(data)