Skip to content

Commit 5653367

Browse files
authored
Throw exception when part is accessed that is not requested (#59)
1 parent 0449843 commit 5653367

35 files changed

+647
-12
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ repos:
7676
language: system
7777
types: [text]
7878
exclude: ^poetry\.lock$
79-
entry: poetry run codespell
79+
entry: poetry run codespell --skip="./.*,*.csv,*.json"
8080
- id: detect-private-key
8181
name: 🕵️ Detect Private Keys
8282
language: system

src/youtubeaio/models.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"YouTubeChannel",
1919
]
2020

21+
from youtubeaio.types import PartMissingError
22+
2123
T = TypeVar("T")
2224

2325

@@ -68,7 +70,14 @@ class YouTubeVideo(BaseModel):
6870
"""Model representing a video."""
6971

7072
video_id: str = Field(..., alias="id")
71-
snippet: YouTubeVideoSnippet | None = None
73+
nullable_snippet: YouTubeVideoSnippet | None = Field(None, alias="snippet")
74+
75+
@property
76+
def snippet(self) -> YouTubeVideoSnippet:
77+
"""Return snippet."""
78+
if self.nullable_snippet is None:
79+
raise PartMissingError
80+
return self.nullable_snippet
7281

7382

7483
class YouTubeChannelThumbnails(BaseModel):
@@ -125,18 +134,42 @@ class YouTubeChannel(BaseModel):
125134
"""Model representing a YouTube channel."""
126135

127136
channel_id: str = Field(..., alias="id")
128-
snippet: YouTubeChannelSnippet | None = None
129-
content_details: YouTubeChannelContentDetails | None = Field(
137+
nullable_snippet: YouTubeChannelSnippet | None = Field(None, alias="snippet")
138+
nullable_content_details: YouTubeChannelContentDetails | None = Field(
130139
None,
131140
alias="contentDetails",
132141
)
133-
statistics: YouTubeChannelStatistics | None = None
142+
nullable_statistics: YouTubeChannelStatistics | None = Field(
143+
None,
144+
alias="statistics",
145+
)
134146

135147
@property
136148
def upload_playlist_id(self) -> str:
137149
"""Return playlist id with uploads from channel."""
138150
return str(self.channel_id).replace("UC", "UU", 1)
139151

152+
@property
153+
def snippet(self) -> YouTubeChannelSnippet:
154+
"""Return snippet."""
155+
if self.nullable_snippet is None:
156+
raise PartMissingError
157+
return self.nullable_snippet
158+
159+
@property
160+
def content_details(self) -> YouTubeChannelContentDetails:
161+
"""Return content details."""
162+
if self.nullable_content_details is None:
163+
raise PartMissingError
164+
return self.nullable_content_details
165+
166+
@property
167+
def statistics(self) -> YouTubeChannelStatistics:
168+
"""Return statistics."""
169+
if self.nullable_statistics is None:
170+
raise PartMissingError
171+
return self.nullable_statistics
172+
140173

141174
class YouTubeSubscriptionSnippet(BaseModel):
142175
"""Model representing a YouTube subscription snippet."""
@@ -156,7 +189,14 @@ class YouTubeSubscription(BaseModel):
156189
"""Model representing a YouTube subscription."""
157190

158191
subscription_id: str = Field(..., alias="id")
159-
snippet: YouTubeSubscriptionSnippet | None = None
192+
nullable_snippet: YouTubeSubscriptionSnippet | None = Field(None, alias="snippet")
193+
194+
@property
195+
def snippet(self) -> YouTubeSubscriptionSnippet:
196+
"""Return snippet."""
197+
if self.nullable_snippet is None:
198+
raise PartMissingError
199+
return self.nullable_snippet
160200

161201

162202
class YouTubePlaylistItemSnippet(BaseModel):
@@ -179,8 +219,22 @@ class YouTubePlaylistItem(BaseModel):
179219
"""Model representing a YouTube playlist item."""
180220

181221
playlist_item_id: str = Field(..., alias="id")
182-
snippet: YouTubePlaylistItemSnippet | None = Field(None)
183-
content_details: YouTubePlaylistItemContentDetails | None = Field(
222+
nullable_snippet: YouTubePlaylistItemSnippet | None = Field(None, alias="snippet")
223+
nullable_content_details: YouTubePlaylistItemContentDetails | None = Field(
184224
None,
185225
alias="contentDetails",
186226
)
227+
228+
@property
229+
def snippet(self) -> YouTubePlaylistItemSnippet:
230+
"""Return snippet."""
231+
if self.nullable_snippet is None:
232+
raise PartMissingError
233+
return self.nullable_snippet
234+
235+
@property
236+
def content_details(self) -> YouTubePlaylistItemContentDetails:
237+
"""Return content details."""
238+
if self.nullable_content_details is None:
239+
raise PartMissingError
240+
return self.nullable_content_details

src/youtubeaio/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ class YouTubeBackendError(YouTubeAPIError):
5959
"""When the YouTube API itself is down."""
6060

6161

62+
class PartMissingError(YouTubeAPIError):
63+
"""If you request a part which is not requested."""
64+
65+
6266
class MissingAppSecretError(YouTubeAPIError):
6367
"""When the app secret is not set but app authorization is attempted."""
6468

tests/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
11
"""Tests for the YouTube Library."""
2+
import json
23
from pathlib import Path
4+
from typing import Any
35

46

57
def load_fixture(filename: str) -> str:
68
"""Load a fixture."""
79
path = Path(__package__) / "fixtures" / filename
810
return path.read_text(encoding="utf-8")
11+
12+
13+
def construct_fixture(object_type: str, parts: list[str], object_number: int) -> Any:
14+
"""Construct a fixture from different files."""
15+
base_path = Path(__package__) / "fixtures" / object_type
16+
base_json = json.loads((base_path / "base.json").read_text(encoding="utf-8"))
17+
18+
object_json = base_path / str(object_number)
19+
base_object_json = json.loads(
20+
(object_json / "base.json").read_text(encoding="utf-8"),
21+
)
22+
for part in parts:
23+
part_json_path = object_json / f"{part}.json"
24+
part_json = json.loads(part_json_path.read_text(encoding="utf-8"))
25+
base_object_json[part] = part_json
26+
base_json["items"].append(base_object_json)
27+
return base_json

tests/fixtures/channel/1/base.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"kind": "youtube#channel",
3+
"etag": "PT8fdvojgSn53JoWCx_QVmL-M1E",
4+
"id": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
5+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"channel": {
3+
"title": "Google for Developers",
4+
"description": "Subscribe to join a community of creative developers and learn the latest in Google technology — from AI and cloud, to mobile and web.\n\nExplore more at developers.google.com\n\n",
5+
"keywords": "\"google developers\" developers \"Google developers videos\" \"google developer tutorials\" \"developer tutorials\" \"developer news\" android firebase tensorflow chrome web flutter \"google developer experts\" \"google launchpad\" \"developer updates\" google \"google design\"",
6+
"trackingAnalyticsAccountId": "YT-9170156-1",
7+
"unsubscribedTrailer": "bC8fvcpocBU",
8+
"country": "US"
9+
},
10+
"image": {
11+
"bannerExternalUrl": "https://yt3.googleusercontent.com/Oxa3gK3h8fgC8wNLkMJVcj2VJ_rRzZL02eAEEVOiKpts-ed5LApGEKZTJ2YrmrsRq6lrJRmZ9g"
12+
}
13+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"relatedPlaylists": {
3+
"likes": "",
4+
"uploads": "UU_x5XG1OV2P6uZZ5FSM9Ttw"
5+
}
6+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"title": "Google for Developers",
3+
"description": "Subscribe to join a community of creative developers and learn the latest in Google technology — from AI and cloud, to mobile and web.\n\nExplore more at developers.google.com\n\n",
4+
"customUrl": "@googledevelopers",
5+
"publishedAt": "2007-08-23T00:34:43Z",
6+
"thumbnails": {
7+
"default": {
8+
"url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s88-c-k-c0x00ffffff-no-rj",
9+
"width": 88,
10+
"height": 88
11+
},
12+
"medium": {
13+
"url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s240-c-k-c0x00ffffff-no-rj",
14+
"width": 240,
15+
"height": 240
16+
},
17+
"high": {
18+
"url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj",
19+
"width": 800,
20+
"height": 800
21+
}
22+
},
23+
"localized": {
24+
"title": "Google for Developers",
25+
"description": "Subscribe to join a community of creative developers and learn the latest in Google technology — from AI and cloud, to mobile and web.\n\nExplore more at developers.google.com\n\n"
26+
},
27+
"country": "US"
28+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"viewCount": "234084903",
3+
"subscriberCount": "2300000",
4+
"hiddenSubscriberCount": false,
5+
"videoCount": "5783"
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"privacyStatus": "public",
3+
"isLinked": true,
4+
"longUploadsStatus": "longUploadsUnspecified",
5+
"madeForKids": false
6+
}

0 commit comments

Comments
 (0)