Skip to content

Commit aebb25f

Browse files
committed
Refactor documentation and architecture
1 parent 7befd94 commit aebb25f

36 files changed

+1411
-416
lines changed

.github/workflows/document.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,10 @@ jobs:
2323
- name: Run
2424
run: |
2525
poetry run poe document
26+
- name: Deploy
27+
uses: peaceiris/actions-gh-pages@v3
28+
with:
29+
github_token: ${{ secrets.GITHUB_TOKEN }}
30+
publish_dir: ./documentation/module/_build/html
31+
publish_branch: documentation
32+
force_orphan: true

.github/workflows/release.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: release
2+
3+
on:
4+
release:
5+
types: [created]
6+
7+
jobs:
8+
build:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v2
12+
- name: Configure
13+
uses: actions/setup-python@v2
14+
with:
15+
python-version: 3.9
16+
- name: Install
17+
run: |
18+
python -m pip install --upgrade pip
19+
pip install poetry
20+
poetry install
21+
- name: Run
22+
run: |
23+
poetry build
24+
poetry publish
25+
env:
26+
POETRY_PYPI_TOKEN_PYPI: ${{env.PYPI_TOKEN}}

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,14 @@ dmypy.json
127127

128128
# Pyre type checker
129129
.pyre/
130+
131+
# macOS
132+
.DS_Store
133+
134+
# Documentation
135+
documentation/module/*
136+
!documentation/module/_build
137+
documentation/module/_build/*
138+
!documentation/module/_build/html
139+
documentation/module/_build/html/*
140+
!documentation/module/_build/html/index.html

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,16 @@
2323
Unofficial toolkit to convert MusicXML files into [Blob Opera][1] scores with
2424
real lyrics, loosely inspired by [OverlappingElvis/blob-opera-midi][2].
2525

26+
## Documentation
27+
28+
* Full [command documentation][12].
29+
* Generated [module documentation][19].
30+
2631
## Samples
2732

2833
* **[Adeste Fideles][5]** ([_source_][7], [_information_][6])
2934
* **[Symphony No. 9 (Beethoven)][13]** ([_source_][15], [_information_][14])
30-
* **[_La bomba_ (Mateo Flecha)][16]** ([_source_][18], [_information_][17])
35+
* **[Ave Maria (Schubert)][20]** ([_source_][21], [_information_][22])
3136

3237
## Usage
3338

@@ -54,8 +59,6 @@ real lyrics, loosely inspired by [OverlappingElvis/blob-opera-midi][2].
5459

5560
5. Visit the generated link with your browser.
5661

57-
> :book: ***You can also read the full [command documentation][12]***
58-
5962
## Known issues
6063

6164
* Pronunciation is far from perfect and consonants may be too faint
@@ -89,3 +92,7 @@ validate your code before starting a pull request.
8992
[16]: https://artsandculture.google.com/experiment/blob-opera/AAHWrq360NcGbw?cp=eyJyIjoiNVNxb0RhRlB1VnRuIn0.
9093
[17]: https://en.wikipedia.org/wiki/Mateo_Flecha
9194
[18]: https://musescore.com/user/28092/scores/85307
95+
[19]: https://0x2b3bfa0.github.io/python-blobopera
96+
[20]: https://g.co/arts/xQGR5aWBwuDeGqTq8
97+
[21]: http://www.cafe-puccini.dk/Schubert_GdurMesse.aspx
98+
[22]: https://en.wikipedia.org/wiki/Ave_Maria_(Schubert)

blobopera/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Unofficial Blob Opera toolkit."""
22

3-
__version__ = "0.2.0"
3+
__version__ = "1.0.0"

blobopera/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Main entry point when running through ``python -m``."""
2+
13
from . import command
24

35
command.application(prog_name=__name__.split(".")[0])

blobopera/backend/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1+
"""Backend interface.
2+
3+
This module provides an interface to upload and download
4+
recordings and other artifacts from the servers.
5+
"""
6+
17
from .backend import Backend

blobopera/backend/backend.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,69 @@
99

1010
@dataclass
1111
class Backend:
12+
"""Interface for interacting directly with the server backend.
13+
14+
Arguments:
15+
public: The host name of the public server.
16+
private: The host name of the private server.
17+
static: The host name of the static server.
18+
shortener: The host name of the link shortener server.
19+
"""
20+
1221
public: str = "artsandculture.google.com"
1322
private: str = "cilex-aeiopera.uc.r.appspot.com"
1423
static: str = "gacembed.withgoogle.com"
1524
shortener: str = "g.co"
1625

1726
def shorten(self, link: str) -> str:
18-
"""Shorten a link with the internal service."""
27+
"""Shorten a link with the internal shortener service.
28+
29+
Arguments:
30+
link: The link to shorten.
31+
32+
Returns:
33+
A shortened link from g.co
34+
35+
Raises:
36+
KeyError: If the shortener did not reply with a link.
37+
"""
1938
address = f"https://{self.public}/api/shortUrl"
2039
response = requests.get(address, params={"destUrl": link})
21-
return re.search(r'.*"(https?://.+?)".*', response.text).group(1)
40+
# We can't parse the response as JSON because it includes garbage.
41+
if match := re.search(r'.*"(https?://.+?)".*', response.text):
42+
return match.group(1)
43+
else:
44+
raise KeyError("no link found")
2245

2346
def link(self, identifier: str) -> str:
24-
"""Generate a link for a given recording identifier."""
47+
"""Generate a link for the given recording identifier.
48+
49+
Arguments:
50+
identifier: The recording identifier in Base64.
51+
52+
Returns:
53+
A long link pointing to the recording on the main Blob Opera page.
54+
"""
55+
# Generate the indentifier bytes.
2556
data = f'{{"r":"{identifier}"}}'.encode()
26-
# Encode the result with a custom Base64 URL-safe extended variant
57+
# Encode the result with a custom Base64 URL-safe extended variant.
2758
code = base64.urlsafe_b64encode(data).decode().replace("=", ".")
28-
# Return the link with the base prefix and the calculated identifier
59+
# Return the link with the base prefix and the calculated identifier.
2960
address = f"https://{self.public}/experiment/blob-opera/AAHWrq360NcGbw"
3061
return f"{address}?cp={code}"
3162

3263
def upload(self, recording: bytes) -> str:
33-
"""Upload a recording to the server and return its identifier."""
64+
"""Upload the given recording to the server and return its identifier.
65+
66+
Arguments:
67+
recording: The recording, serialized with its protocol buffer.
68+
69+
Returns:
70+
A recording identifier.
71+
72+
Raises:
73+
ValueError: If the uploaded recording was rejected by the server.
74+
"""
3475

3576
address = f"https://{self.private}/recording"
3677
response = requests.put(address, data=recording)
@@ -41,17 +82,35 @@ def upload(self, recording: bytes) -> str:
4182
raise ValueError("invalid recording")
4283

4384
def download(self, handle: str) -> bytes:
44-
"""Download a recording from the server and return its contents."""
85+
"""Download a recording from the server and return its contents.
86+
87+
Arguments:
88+
handle: The recording handle, be it a short link, a long link or
89+
a recording identifier.
90+
91+
Returns:
92+
A raw protocol buffer message with the recording.
93+
94+
Raises:
95+
KeyError: If the recording was not found on the server.
96+
"""
4597
try:
98+
# If it's a short link, try to resolve the long link.
4699
if handle.startswith(f"https://{self.shortener}"):
47100
handle = requests.get(handle).url
101+
102+
# If it's a long link, try to retrieve the identifier.
48103
if handle.startswith(f"https://{self.public}"):
49-
code, *_ = urllib.parse.parse_qs(
50-
urllib.parse.urlparse(handle).query
51-
)["cp"]
104+
# Extract the query string from the address.
105+
query_string = urllib.parse.urlparse(handle).query
106+
# Extract the ``cp`` parameter from the query string.
107+
code, *_ = urllib.parse.parse_qs(query_string)["cp"]
108+
# Decode the ``cp`` parameter with the custom url-safe Base64.
52109
raw = base64.urlsafe_b64decode(code.replace(".", "="))
110+
# Extract the recording identifier.
53111
handle = json.loads(raw)["r"]
54112

113+
# Fetch the recording and return the raw protocol buffer.
55114
address = f"https://{self.private}/recording/{handle}"
56115
file = requests.get(address).json()["url"]
57116
return requests.get(file).content

blobopera/command/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,25 @@ def main(
1313
static_host: str = Backend.static,
1414
shortener_host: str = Backend.shortener,
1515
):
16-
"""Initialize a backend instance to be shared amongst subcommands."""
16+
"""Initialize a backend instance to be shared amongst subcommands.
17+
18+
Note:
19+
This function acts as the main application callback, and its only
20+
purpose is creating a singleton (more or less) backend object.
21+
"""
1722
context.obj = Backend(
1823
public_host, private_host, static_host, shortener_host
1924
)
2025

2126

2227
# Create the application with the documentation string and the main callback.
23-
application = typer.Typer(help=__doc__, callback=main)
28+
application = typer.Typer(help=__doc__.splitlines()[0], callback=main)
2429

2530

2631
# Add each command to the main application.
2732
for command in jitter, libretto, recording:
2833
application.add_typer(
2934
command.application,
30-
name=command.__name__.split(".")[-1], # Last component of module name.
35+
name=command.__name__.split(".")[-1], # Last component.
3136
help=command.__doc__, # Documentation string.
3237
)

blobopera/command/common.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
"""Common enumerations and functions.
2+
3+
This file provides data import functions and some shared defaults and
4+
enumerations used by choice-like subcommand options.
5+
"""
16
from enum import Enum
27
from typing import Type
38

49
import typer
510
from google.protobuf.json_format import ParseError
611
from google.protobuf.message import DecodeError, EncodeError
7-
from proto import Message
12+
from proto import Message # type: ignore
813

914

1015
class ConvertFormat(str, Enum):
@@ -87,7 +92,15 @@ class InterfaceTheme(str, Enum):
8792

8893

8994
def parse(data: bytes, message: Type[Message]) -> Message:
90-
"""Parse a Protocol Buffer message from any of its representations."""
95+
"""Parse a Protocol Buffer message from any of its representations.
96+
97+
Arguments:
98+
data: the input data, either raw protocol buffer bytes or JSON bytes.
99+
message: the class (not an instance!) of the protocol buffer message.
100+
101+
Returns:
102+
An instance of the given message type.
103+
"""
91104
try:
92105
try:
93106
# Try to interpret the input data as a JSON object.
@@ -108,13 +121,22 @@ def parse(data: bytes, message: Type[Message]) -> Message:
108121
def convert(
109122
input: bytes, format: ConvertFormat, message: Type[Message]
110123
) -> bytes:
111-
"""Convert a Protocol Buffer message between its representations."""
124+
"""Convert a Protocol Buffer message between its representations.
125+
126+
Arguments:
127+
data: the input data, either raw protocol buffer bytes or JSON bytes.
128+
format: the output format for the conversion result.
129+
message: the class (not an instance!) of the protocol buffer message.
130+
131+
Returns:
132+
The converted data.
133+
"""
112134

113135
structure = parse(input, message)
114136
if format == ConvertFormat.JSON:
115137
data: bytes = message.to_json(structure).encode()
116138
elif format == ConvertFormat.BINARY:
117-
data: bytes = message.serialize(structure)
139+
data = message.serialize(structure)
118140
else:
119141
raise ValueError("invalid format")
120142

0 commit comments

Comments
 (0)