Skip to content

Commit

Permalink
Refactor documentation and architecture
Browse files Browse the repository at this point in the history
  • Loading branch information
0x2b3bfa0 committed Jan 15, 2021
1 parent 7befd94 commit aebb25f
Show file tree
Hide file tree
Showing 36 changed files with 1,411 additions and 416 deletions.
7 changes: 7 additions & 0 deletions .github/workflows/document.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ jobs:
- name: Run
run: |
poetry run poe document
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./documentation/module/_build/html
publish_branch: documentation
force_orphan: true
26 changes: 26 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: release

on:
release:
types: [created]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Configure
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install
run: |
python -m pip install --upgrade pip
pip install poetry
poetry install
- name: Run
run: |
poetry build
poetry publish
env:
POETRY_PYPI_TOKEN_PYPI: ${{env.PYPI_TOKEN}}
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,14 @@ dmypy.json

# Pyre type checker
.pyre/

# macOS
.DS_Store

# Documentation
documentation/module/*
!documentation/module/_build
documentation/module/_build/*
!documentation/module/_build/html
documentation/module/_build/html/*
!documentation/module/_build/html/index.html
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@
Unofficial toolkit to convert MusicXML files into [Blob Opera][1] scores with
real lyrics, loosely inspired by [OverlappingElvis/blob-opera-midi][2].

## Documentation

* Full [command documentation][12].
* Generated [module documentation][19].

## Samples

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

## Usage

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

5. Visit the generated link with your browser.

> :book: ***You can also read the full [command documentation][12]***

## Known issues

* Pronunciation is far from perfect and consonants may be too faint
Expand Down Expand Up @@ -89,3 +92,7 @@ validate your code before starting a pull request.
[16]: https://artsandculture.google.com/experiment/blob-opera/AAHWrq360NcGbw?cp=eyJyIjoiNVNxb0RhRlB1VnRuIn0.
[17]: https://en.wikipedia.org/wiki/Mateo_Flecha
[18]: https://musescore.com/user/28092/scores/85307
[19]: https://0x2b3bfa0.github.io/python-blobopera
[20]: https://g.co/arts/xQGR5aWBwuDeGqTq8
[21]: http://www.cafe-puccini.dk/Schubert_GdurMesse.aspx
[22]: https://en.wikipedia.org/wiki/Ave_Maria_(Schubert)
2 changes: 1 addition & 1 deletion blobopera/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Unofficial Blob Opera toolkit."""

__version__ = "0.2.0"
__version__ = "1.0.0"
2 changes: 2 additions & 0 deletions blobopera/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Main entry point when running through ``python -m``."""

from . import command

command.application(prog_name=__name__.split(".")[0])
6 changes: 6 additions & 0 deletions blobopera/backend/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
"""Backend interface.
This module provides an interface to upload and download
recordings and other artifacts from the servers.
"""

from .backend import Backend
79 changes: 69 additions & 10 deletions blobopera/backend/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,69 @@

@dataclass
class Backend:
"""Interface for interacting directly with the server backend.
Arguments:
public: The host name of the public server.
private: The host name of the private server.
static: The host name of the static server.
shortener: The host name of the link shortener server.
"""

public: str = "artsandculture.google.com"
private: str = "cilex-aeiopera.uc.r.appspot.com"
static: str = "gacembed.withgoogle.com"
shortener: str = "g.co"

def shorten(self, link: str) -> str:
"""Shorten a link with the internal service."""
"""Shorten a link with the internal shortener service.
Arguments:
link: The link to shorten.
Returns:
A shortened link from g.co
Raises:
KeyError: If the shortener did not reply with a link.
"""
address = f"https://{self.public}/api/shortUrl"
response = requests.get(address, params={"destUrl": link})
return re.search(r'.*"(https?://.+?)".*', response.text).group(1)
# We can't parse the response as JSON because it includes garbage.
if match := re.search(r'.*"(https?://.+?)".*', response.text):
return match.group(1)
else:
raise KeyError("no link found")

def link(self, identifier: str) -> str:
"""Generate a link for a given recording identifier."""
"""Generate a link for the given recording identifier.
Arguments:
identifier: The recording identifier in Base64.
Returns:
A long link pointing to the recording on the main Blob Opera page.
"""
# Generate the indentifier bytes.
data = f'{{"r":"{identifier}"}}'.encode()
# Encode the result with a custom Base64 URL-safe extended variant
# Encode the result with a custom Base64 URL-safe extended variant.
code = base64.urlsafe_b64encode(data).decode().replace("=", ".")
# Return the link with the base prefix and the calculated identifier
# Return the link with the base prefix and the calculated identifier.
address = f"https://{self.public}/experiment/blob-opera/AAHWrq360NcGbw"
return f"{address}?cp={code}"

def upload(self, recording: bytes) -> str:
"""Upload a recording to the server and return its identifier."""
"""Upload the given recording to the server and return its identifier.
Arguments:
recording: The recording, serialized with its protocol buffer.
Returns:
A recording identifier.
Raises:
ValueError: If the uploaded recording was rejected by the server.
"""

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

def download(self, handle: str) -> bytes:
"""Download a recording from the server and return its contents."""
"""Download a recording from the server and return its contents.
Arguments:
handle: The recording handle, be it a short link, a long link or
a recording identifier.
Returns:
A raw protocol buffer message with the recording.
Raises:
KeyError: If the recording was not found on the server.
"""
try:
# If it's a short link, try to resolve the long link.
if handle.startswith(f"https://{self.shortener}"):
handle = requests.get(handle).url

# If it's a long link, try to retrieve the identifier.
if handle.startswith(f"https://{self.public}"):
code, *_ = urllib.parse.parse_qs(
urllib.parse.urlparse(handle).query
)["cp"]
# Extract the query string from the address.
query_string = urllib.parse.urlparse(handle).query
# Extract the ``cp`` parameter from the query string.
code, *_ = urllib.parse.parse_qs(query_string)["cp"]
# Decode the ``cp`` parameter with the custom url-safe Base64.
raw = base64.urlsafe_b64decode(code.replace(".", "="))
# Extract the recording identifier.
handle = json.loads(raw)["r"]

# Fetch the recording and return the raw protocol buffer.
address = f"https://{self.private}/recording/{handle}"
file = requests.get(address).json()["url"]
return requests.get(file).content
Expand Down
11 changes: 8 additions & 3 deletions blobopera/command/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,25 @@ def main(
static_host: str = Backend.static,
shortener_host: str = Backend.shortener,
):
"""Initialize a backend instance to be shared amongst subcommands."""
"""Initialize a backend instance to be shared amongst subcommands.
Note:
This function acts as the main application callback, and its only
purpose is creating a singleton (more or less) backend object.
"""
context.obj = Backend(
public_host, private_host, static_host, shortener_host
)


# Create the application with the documentation string and the main callback.
application = typer.Typer(help=__doc__, callback=main)
application = typer.Typer(help=__doc__.splitlines()[0], callback=main)


# Add each command to the main application.
for command in jitter, libretto, recording:
application.add_typer(
command.application,
name=command.__name__.split(".")[-1], # Last component of module name.
name=command.__name__.split(".")[-1], # Last component.
help=command.__doc__, # Documentation string.
)
30 changes: 26 additions & 4 deletions blobopera/command/common.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"""Common enumerations and functions.
This file provides data import functions and some shared defaults and
enumerations used by choice-like subcommand options.
"""
from enum import Enum
from typing import Type

import typer
from google.protobuf.json_format import ParseError
from google.protobuf.message import DecodeError, EncodeError
from proto import Message
from proto import Message # type: ignore


class ConvertFormat(str, Enum):
Expand Down Expand Up @@ -87,7 +92,15 @@ class InterfaceTheme(str, Enum):


def parse(data: bytes, message: Type[Message]) -> Message:
"""Parse a Protocol Buffer message from any of its representations."""
"""Parse a Protocol Buffer message from any of its representations.
Arguments:
data: the input data, either raw protocol buffer bytes or JSON bytes.
message: the class (not an instance!) of the protocol buffer message.
Returns:
An instance of the given message type.
"""
try:
try:
# Try to interpret the input data as a JSON object.
Expand All @@ -108,13 +121,22 @@ def parse(data: bytes, message: Type[Message]) -> Message:
def convert(
input: bytes, format: ConvertFormat, message: Type[Message]
) -> bytes:
"""Convert a Protocol Buffer message between its representations."""
"""Convert a Protocol Buffer message between its representations.
Arguments:
data: the input data, either raw protocol buffer bytes or JSON bytes.
format: the output format for the conversion result.
message: the class (not an instance!) of the protocol buffer message.
Returns:
The converted data.
"""

structure = parse(input, message)
if format == ConvertFormat.JSON:
data: bytes = message.to_json(structure).encode()
elif format == ConvertFormat.BINARY:
data: bytes = message.serialize(structure)
data = message.serialize(structure)
else:
raise ValueError("invalid format")

Expand Down
4 changes: 2 additions & 2 deletions blobopera/command/libretto.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ def export(
"""Export phonemes of recorded libretto in a human-friendly format."""
corpus: Corpus = common.parse(input.read(), Corpus)

for libretto in corpus.librettos:
for fragment in corpus.fragments:
hyphenated = " ".join(
Phoneme(timed.phoneme).name for timed in libretto.phonemes
Phoneme(timed.phoneme).name for timed in fragment.phonemes
).replace(Phoneme.SILENCE.name, "-")
print(hyphenated, file=output)
Loading

0 comments on commit aebb25f

Please sign in to comment.