Skip to content

Commit c23d29e

Browse files
authored
Overhaul (#46)
1 parent bb4c58a commit c23d29e

14 files changed

Lines changed: 749 additions & 197 deletions

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ disable = [
121121
"format",
122122
"unsubscriptable-object",
123123
"unnecessary-dunder-call",
124+
"too-many-instance-attributes",
125+
"too-many-arguments",
126+
"too-many-locals",
124127
]
125128

126129
[tool.pylint.SIMILARITIES]
@@ -144,6 +147,9 @@ ignore = [
144147
"PLR2004", # Just annoying, not really useful
145148
"TCH001",
146149
"SLOT000",
150+
"FBT001", # Boolean positional arg
151+
"FBT002", # Boolean default arg
152+
"PLR0913", # A lot of arguments
147153
]
148154
select = ["ALL"]
149155

src/async_python_youtube/exceptions.py

Lines changed: 0 additions & 21 deletions
This file was deleted.

src/async_python_youtube/helper.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,82 @@
11
"""Helper functions for the YouTube API."""
2+
import urllib.parse
23
from collections.abc import AsyncGenerator, Generator
3-
from typing import TypeVar
4+
from enum import Enum
5+
from typing import Any, TypeVar
6+
7+
from async_python_youtube.types import AuthScope
8+
9+
__all__ = [
10+
"YOUTUBE_AUTH_BASE_URL",
11+
"YOUTUBE_AUTH_TOKEN_URL",
12+
"build_scope",
13+
"build_url",
14+
"first",
15+
"chunk",
16+
"limit",
17+
]
418

519
T = TypeVar("T")
620

21+
YOUTUBE_AUTH_BASE_URL: str = "https://oauth2.googleapis.com"
22+
YOUTUBE_AUTH_TOKEN_URL: str = f"{YOUTUBE_AUTH_BASE_URL}/token"
23+
24+
25+
def build_scope(scopes: list[AuthScope]) -> str:
26+
"""Build a valid scope string from list.
27+
28+
:param scopes: list of :class:`~async_python_youtube.types.AuthScope`
29+
:returns: the valid auth scope string
30+
"""
31+
return " ".join([s.value for s in scopes])
32+
33+
34+
def build_url(
35+
url: str,
36+
params: dict[str, Any],
37+
remove_none: bool = False,
38+
split_lists: bool = False,
39+
enum_value: bool = True,
40+
) -> str:
41+
"""Build a valid url string.
42+
43+
:param url: base URL
44+
:param params: dictionary of URL parameter
45+
:param remove_none: if set all params that have a None value get removed
46+
|default| :code:`False`
47+
:param split_lists: if set all params that are a list will be split over multiple
48+
url parameter with the same name |default| :code:`False`
49+
:param enum_value: if true, automatically get value string from Enum values
50+
|default| :code:`True`
51+
:return: URL
52+
"""
53+
54+
def get_value(input_value: Any) -> str:
55+
if not enum_value:
56+
return str(input_value)
57+
if isinstance(input_value, Enum):
58+
return str(input_value.value)
59+
return str(input_value)
60+
61+
def add_param(res: str, query_key: str, query_value: Any) -> str:
62+
if len(res) > 0:
63+
res += "&"
64+
res += query_key
65+
if query_value is not None:
66+
res += "=" + urllib.parse.quote(get_value(query_value))
67+
return res
68+
69+
result = ""
70+
for key, value in params.items():
71+
if value is None and remove_none:
72+
continue
73+
if split_lists and isinstance(value, list):
74+
for val in value:
75+
result = add_param(result, key, val)
76+
else:
77+
result = add_param(result, key, value)
78+
return url + (("?" + result) if len(result) > 0 else "")
79+
780

881
async def first(generator: AsyncGenerator[T, None]) -> T | None:
982
"""Return the first value or None from the given AsyncGenerator."""

src/async_python_youtube/models.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,22 @@
66

77
from async_python_youtube.const import LiveBroadcastContent
88

9+
__all__ = [
10+
"YouTubeThumbnail",
11+
"YouTubeVideoThumbnails",
12+
"YouTubeVideoSnippet",
13+
"YouTubeVideo",
14+
"YouTubeChannelThumbnails",
15+
"YouTubeChannelRelatedPlaylists",
16+
"YouTubeChannelContentDetails",
17+
"YouTubeChannelSnippet",
18+
"YouTubeChannel",
19+
]
20+
921
T = TypeVar("T")
1022

1123

12-
class YouTubeVideoThumbnail(BaseModel):
24+
class YouTubeThumbnail(BaseModel):
1325
"""Model representing a video thumbnail."""
1426

1527
url: str = Field(...)
@@ -20,11 +32,11 @@ class YouTubeVideoThumbnail(BaseModel):
2032
class YouTubeVideoThumbnails(BaseModel):
2133
"""Model representing video thumbnails."""
2234

23-
default: YouTubeVideoThumbnail = Field(...)
24-
medium: YouTubeVideoThumbnail = Field(...)
25-
high: YouTubeVideoThumbnail = Field(...)
26-
standard: YouTubeVideoThumbnail = Field(...)
27-
maxres: YouTubeVideoThumbnail | None = Field(None)
35+
default: YouTubeThumbnail = Field(...)
36+
medium: YouTubeThumbnail = Field(...)
37+
high: YouTubeThumbnail = Field(...)
38+
standard: YouTubeThumbnail = Field(...)
39+
maxres: YouTubeThumbnail | None = Field(None)
2840

2941

3042
class YouTubeVideoSnippet(BaseModel):
@@ -36,7 +48,7 @@ class YouTubeVideoSnippet(BaseModel):
3648
description: str = Field(...)
3749
thumbnails: YouTubeVideoThumbnails = Field(...)
3850
channel_title: str = Field(..., alias="channelTitle")
39-
tags: list[str] = Field(...)
51+
tags: list[str] = Field([])
4052
live_broadcast_content: LiveBroadcastContent = Field(
4153
...,
4254
alias="liveBroadcastContent",
@@ -50,3 +62,48 @@ class YouTubeVideo(BaseModel):
5062

5163
video_id: str = Field(..., alias="id")
5264
snippet: YouTubeVideoSnippet | None = None
65+
66+
67+
class YouTubeChannelThumbnails(BaseModel):
68+
"""Model representing channel thumbnails."""
69+
70+
default: YouTubeThumbnail = Field(...)
71+
medium: YouTubeThumbnail = Field(...)
72+
high: YouTubeThumbnail = Field(...)
73+
74+
75+
class YouTubeChannelRelatedPlaylists(BaseModel):
76+
"""Model representing related playlists of a channel."""
77+
78+
likes: str = Field(...)
79+
uploads: str = Field(...)
80+
81+
82+
class YouTubeChannelContentDetails(BaseModel):
83+
"""Model representing content details of a channel."""
84+
85+
related_playlists: YouTubeChannelRelatedPlaylists = Field(
86+
...,
87+
alias="relatedPlaylists",
88+
)
89+
90+
91+
class YouTubeChannelSnippet(BaseModel):
92+
"""Model representing channel snippet."""
93+
94+
title: str = Field(...)
95+
description: str = Field(...)
96+
published_at: datetime = Field(..., alias="publishedAt")
97+
thumbnails: YouTubeChannelThumbnails = Field(...)
98+
default_language: str | None = Field(None, alias="defaultLanguage")
99+
100+
101+
class YouTubeChannel(BaseModel):
102+
"""Model representing a YouTube channel."""
103+
104+
channel_id: str = Field(..., alias="id")
105+
snippet: YouTubeChannelSnippet | None = None
106+
content_details: YouTubeChannelContentDetails | None = Field(
107+
None,
108+
alias="contentDetails",
109+
)

src/async_python_youtube/oauth.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Oauth helpers for YouTube."""
2+
import aiohttp
3+
4+
from async_python_youtube.helper import YOUTUBE_AUTH_TOKEN_URL, build_url
5+
from async_python_youtube.types import InvalidRefreshTokenError, UnauthorizedError
6+
7+
__all__ = ["refresh_access_token"]
8+
9+
10+
async def refresh_access_token(
11+
refresh_token: str,
12+
app_id: str,
13+
app_secret: str,
14+
session: aiohttp.ClientSession | None = None,
15+
) -> tuple[str, str]:
16+
"""Refresh a user access token.
17+
18+
:param str refresh_token: the current refresh_token
19+
:param str app_id: the id of your app
20+
:param str app_secret: the secret key of your app
21+
:param ~aiohttp.ClientSession session: optionally a active client session to be
22+
used for the web request to avoid having to open a new one
23+
:return: access_token, refresh_token
24+
:raises ~async_python_youtube.types.InvalidRefreshTokenException: if refresh token
25+
is invalid
26+
:raises ~async_python_youtube.types.UnauthorizedException: if both refresh and
27+
access token are invalid (e.g. if the user changes their password of the app gets
28+
disconnected)
29+
:rtype: (str, str)
30+
"""
31+
param = {
32+
"refresh_token": refresh_token,
33+
"client_id": app_id,
34+
"grant_type": "refresh_token",
35+
"client_secret": app_secret,
36+
"access_type": "offline",
37+
"prompt": "consent",
38+
}
39+
url = build_url(YOUTUBE_AUTH_TOKEN_URL, {})
40+
ses = session if session is not None else aiohttp.ClientSession()
41+
async with ses.post(url, data=param) as result:
42+
data = await result.json()
43+
if session is None:
44+
await ses.close()
45+
if result.status == 400:
46+
raise InvalidRefreshTokenError(data.get("error", ""))
47+
if result.status == 401:
48+
raise UnauthorizedError(data.get("error", ""))
49+
return data["access_token"], refresh_token

src/async_python_youtube/types.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Type definitions."""
2+
from enum import Enum
3+
4+
__all__ = [
5+
"AuthScope",
6+
"YouTubeAPIError",
7+
"YouTubeAuthorizationError",
8+
"InvalidRefreshTokenError",
9+
"InvalidTokenError",
10+
"UnauthorizedError",
11+
"MissingScopeError",
12+
"YouTubeBackendError",
13+
"MissingAppSecretError",
14+
"DeprecatedError",
15+
"YouTubeResourceNotFoundError",
16+
"ForbiddenError",
17+
]
18+
19+
20+
class AuthScope(Enum):
21+
"""Enum of authentication scopes."""
22+
23+
MANAGE = "https://www.googleapis.com/auth/youtube"
24+
MANAGE_MEMBERSHIPS = (
25+
"https://www.googleapis.com/auth/youtube.channel-memberships.creator"
26+
)
27+
FORCE_SSL = "https://www.googleapis.com/auth/youtube.force-ssl"
28+
READ_ONLY = "https://www.googleapis.com/auth/youtube.readonly"
29+
UPLOAD = "https://www.googleapis.com/auth/youtube.upload"
30+
PARTNER = "https://www.googleapis.com/auth/youtubepartner"
31+
PARTNER_AUDIT = "https://www.googleapis.com/auth/youtubepartner-channel-audit"
32+
33+
34+
class YouTubeAPIError(Exception):
35+
"""Base YouTube API exception."""
36+
37+
38+
class YouTubeAuthorizationError(YouTubeAPIError):
39+
"""Exception in the YouTube Authorization."""
40+
41+
42+
class InvalidRefreshTokenError(YouTubeAPIError):
43+
"""used User Refresh Token is invalid."""
44+
45+
46+
class InvalidTokenError(YouTubeAPIError):
47+
"""Used if an invalid token is set for the client."""
48+
49+
50+
class UnauthorizedError(YouTubeAuthorizationError):
51+
"""Not authorized to use this."""
52+
53+
54+
class MissingScopeError(YouTubeAuthorizationError):
55+
"""authorization is missing scope."""
56+
57+
58+
class YouTubeBackendError(YouTubeAPIError):
59+
"""When the YouTube API itself is down."""
60+
61+
62+
class MissingAppSecretError(YouTubeAPIError):
63+
"""When the app secret is not set but app authorization is attempted."""
64+
65+
66+
class DeprecatedError(YouTubeAPIError):
67+
"""If something has been marked as deprecated by the YouTube API."""
68+
69+
70+
class YouTubeResourceNotFoundError(YouTubeAPIError):
71+
"""If a requested resource was not found."""
72+
73+
74+
class ForbiddenError(YouTubeAPIError):
75+
"""If you are not allowed to do that."""

0 commit comments

Comments
 (0)