Skip to content

Commit 450012f

Browse files
Add helpers for creating and retrieving GitHub gists
1 parent 1b8189b commit 450012f

2 files changed

Lines changed: 189 additions & 0 deletions

File tree

web_programming/github_gist.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Utilities for creating and retrieving GitHub gists."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from typing import Any
7+
8+
import requests
9+
10+
BASE_URL = "https://api.github.com"
11+
CREATE_GIST_ENDPOINT = f"{BASE_URL}/gists"
12+
13+
# GitHub recommends using personal access tokens to authenticate requests.
14+
# The tests patch the network calls so the token can be left empty when running
15+
# locally, but an actual token will be required when using the module for real
16+
# interactions with the GitHub API.
17+
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
18+
19+
20+
class GitHubAPIError(RuntimeError):
21+
"""Raised when the GitHub API returns an error response."""
22+
23+
24+
def _build_headers(auth_token: str | None) -> dict[str, str]:
25+
headers: dict[str, str] = {"Accept": "application/vnd.github.v3+json"}
26+
if auth_token:
27+
headers["Authorization"] = f"token {auth_token}"
28+
return headers
29+
30+
31+
def create_gist(
32+
description: str,
33+
files: dict[str, dict[str, str]],
34+
*,
35+
public: bool = True,
36+
auth_token: str | None = None,
37+
) -> dict[str, Any]:
38+
"""Create a GitHub gist.
39+
40+
Parameters
41+
----------
42+
description:
43+
A short description that appears above the gist files.
44+
files:
45+
Mapping of file names to dictionaries containing a ``content`` key with
46+
the file contents.
47+
public:
48+
Set to ``True`` to create a public gist, otherwise the gist will be
49+
secret.
50+
auth_token:
51+
Optional personal access token. If not supplied, the function will fall
52+
back to the value of ``GITHUB_TOKEN`` from the environment.
53+
54+
Returns
55+
-------
56+
dict
57+
The JSON response from the GitHub API describing the newly created
58+
gist.
59+
60+
Raises
61+
------
62+
GitHubAPIError
63+
If the API returns a non-success status code.
64+
"""
65+
66+
token = auth_token if auth_token is not None else GITHUB_TOKEN
67+
headers = _build_headers(token)
68+
payload = {"description": description, "public": public, "files": files}
69+
70+
response = requests.post(CREATE_GIST_ENDPOINT, json=payload, headers=headers, timeout=10)
71+
if response.status_code >= 400:
72+
raise GitHubAPIError(response.text)
73+
return response.json()
74+
75+
76+
def get_gist(gist_id: str, auth_token: str | None = None) -> dict[str, Any]:
77+
"""Retrieve a gist by its identifier.
78+
79+
Parameters
80+
----------
81+
gist_id:
82+
Unique identifier of the gist returned by :func:`create_gist`.
83+
auth_token:
84+
Optional personal access token. If omitted the ``GITHUB_TOKEN``
85+
environment variable will be used.
86+
87+
Returns
88+
-------
89+
dict
90+
Parsed JSON describing the gist.
91+
92+
Raises
93+
------
94+
GitHubAPIError
95+
If the API returns a non-success status code.
96+
"""
97+
98+
token = auth_token if auth_token is not None else GITHUB_TOKEN
99+
headers = _build_headers(token)
100+
endpoint = f"{CREATE_GIST_ENDPOINT}/{gist_id}"
101+
response = requests.get(endpoint, headers=headers, timeout=10)
102+
if response.status_code >= 400:
103+
raise GitHubAPIError(response.text)
104+
return response.json()
105+
106+
107+
if __name__ == "__main__": # pragma: no cover
108+
if not GITHUB_TOKEN:
109+
raise ValueError("'GITHUB_TOKEN' field cannot be empty.")
110+
111+
example_files = {"file1.txt": {"content": "Aren't gists great!"}}
112+
gist_data = create_gist("Example gist created from Python", example_files)
113+
gist_id = gist_data.get("id", "")
114+
if gist_id:
115+
retrieved = get_gist(gist_id)
116+
print(f"Created gist {gist_id} containing {len(retrieved.get('files', {}))} file(s).")
117+
else:
118+
print("Failed to create gist.")
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import json
2+
from typing import Any
3+
4+
import pytest
5+
import requests
6+
7+
from .github_gist import CREATE_GIST_ENDPOINT, GitHubAPIError, create_gist, get_gist
8+
9+
10+
class FakeResponse:
11+
def __init__(self, status_code: int, data: dict[str, Any] | None = None, text: str = "") -> None:
12+
self.status_code = status_code
13+
self._data = data or {}
14+
self.text = text
15+
16+
def json(self) -> dict[str, Any]:
17+
return json.loads(json.dumps(self._data))
18+
19+
20+
def test_create_gist(monkeypatch):
21+
expected_payload = {
22+
"description": "My first gist",
23+
"public": True,
24+
"files": {"file1.txt": {"content": "Aren't gists great!"}},
25+
}
26+
27+
def mock_post(url, *, json, headers, timeout): # noqa: A002 - required signature
28+
assert url == CREATE_GIST_ENDPOINT
29+
assert json == expected_payload
30+
assert headers["Accept"] == "application/vnd.github.v3+json"
31+
assert headers["Authorization"].startswith("token ")
32+
assert timeout == 10
33+
data = {"id": "example-id", "files": expected_payload["files"]}
34+
return FakeResponse(201, data)
35+
36+
monkeypatch.setattr(requests, "post", mock_post)
37+
38+
response = create_gist(
39+
description="My first gist",
40+
files={"file1.txt": {"content": "Aren't gists great!"}},
41+
auth_token="abc123",
42+
)
43+
assert response["id"] == "example-id"
44+
assert "file1.txt" in response["files"]
45+
46+
47+
def test_get_gist(monkeypatch):
48+
gist_id = "example-id"
49+
expected = {"id": gist_id, "files": {"file1.txt": {"content": "data"}}}
50+
51+
def mock_get(url, *, headers, timeout):
52+
assert url == f"{CREATE_GIST_ENDPOINT}/{gist_id}"
53+
assert headers["Accept"] == "application/vnd.github.v3+json"
54+
assert headers["Authorization"].startswith("token ")
55+
assert timeout == 10
56+
return FakeResponse(200, expected)
57+
58+
monkeypatch.setattr(requests, "get", mock_get)
59+
60+
response = get_gist(gist_id, auth_token="abc123")
61+
assert response == expected
62+
63+
64+
def test_error_is_raised_on_failure(monkeypatch):
65+
def mock_post(url, *, json, headers, timeout): # noqa: A002 - required signature
66+
return FakeResponse(422, text="unprocessable")
67+
68+
monkeypatch.setattr(requests, "post", mock_post)
69+
70+
with pytest.raises(GitHubAPIError):
71+
create_gist("desc", {"file.txt": {"content": "data"}}, auth_token="token")

0 commit comments

Comments
 (0)