From 65fa0b969ec0b8cac9968515dcfbecee9c4fc6fb Mon Sep 17 00:00:00 2001 From: Isaac Lyons Date: Tue, 29 Nov 2022 14:07:48 -0500 Subject: [PATCH] Add type hints to make objects easier to track during development. --- plugin_cmds/cmd_get-annotations.py | 3 +- plugin_cmds/cmd_goodreads-transform.py | 5 ++- plugin_cmds/cmd_image-urls.py | 3 +- plugin_cmds/cmd_listening-stats.py | 7 ++-- src/audible_cli/cli.py | 2 +- src/audible_cli/cmds/cmd_activation_bytes.py | 3 +- src/audible_cli/cmds/cmd_api.py | 3 +- src/audible_cli/cmds/cmd_download.py | 36 ++++++++++++----- src/audible_cli/cmds/cmd_library.py | 16 +++++--- src/audible_cli/cmds/cmd_manage.py | 42 +++++++++++++++----- src/audible_cli/cmds/cmd_quickstart.py | 4 +- src/audible_cli/cmds/cmd_wishlist.py | 18 +++++---- src/audible_cli/exceptions.py | 10 ++--- src/audible_cli/models.py | 28 ++++++------- src/audible_cli/plugins.py | 6 +-- src/audible_cli/utils.py | 14 +++++-- utils/update_chapter_titles.py | 26 ++++++++---- 17 files changed, 149 insertions(+), 77 deletions(-) diff --git a/plugin_cmds/cmd_get-annotations.py b/plugin_cmds/cmd_get-annotations.py index f8d2dcc..044b94b 100644 --- a/plugin_cmds/cmd_get-annotations.py +++ b/plugin_cmds/cmd_get-annotations.py @@ -1,5 +1,6 @@ import click +from audible import AsyncClient from audible.exceptions import NotFoundError from audible_cli.decorators import pass_client @@ -7,7 +8,7 @@ @click.command("get-annotations") @click.argument("asin") @pass_client -async def cli(client, asin): +async def cli(client: AsyncClient, asin: str): url = f"https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/sidecar" params = { "type": "AUDI", diff --git a/plugin_cmds/cmd_goodreads-transform.py b/plugin_cmds/cmd_goodreads-transform.py index f5c0163..fd2195c 100644 --- a/plugin_cmds/cmd_goodreads-transform.py +++ b/plugin_cmds/cmd_goodreads-transform.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone import click +from audible import AsyncClient from audible_cli.decorators import ( bunch_size_option, timeout_option, @@ -29,7 +30,7 @@ @bunch_size_option @pass_session @pass_client -async def cli(session, client, output): +async def cli(session, client: AsyncClient, output: str): """YOUR COMMAND DESCRIPTION""" logger.debug("fetching library") @@ -58,7 +59,7 @@ async def cli(session, client, output): logger.info(f"File saved to {output}") -def _prepare_library_for_export(library): +def _prepare_library_for_export(library: Library): prepared_library = [] isbn_counter = 0 diff --git a/plugin_cmds/cmd_image-urls.py b/plugin_cmds/cmd_image-urls.py index a261256..994f30e 100644 --- a/plugin_cmds/cmd_image-urls.py +++ b/plugin_cmds/cmd_image-urls.py @@ -1,4 +1,5 @@ import click +from audible import AsyncClient from audible_cli.decorators import pass_client, timeout_option @@ -6,7 +7,7 @@ @click.argument("asin") @timeout_option() @pass_client() -async def cli(client, asin): +async def cli(client: AsyncClient, asin: str): """Print out the image urls for different resolutions for a book""" r = await client.get( f"catalog/products/{asin}", diff --git a/plugin_cmds/cmd_listening-stats.py b/plugin_cmds/cmd_listening-stats.py index 99a12cc..65ec279 100644 --- a/plugin_cmds/cmd_listening-stats.py +++ b/plugin_cmds/cmd_listening-stats.py @@ -5,6 +5,7 @@ from datetime import datetime import click +from audible import AsyncClient from audible_cli.decorators import pass_client @@ -13,14 +14,14 @@ current_year = datetime.now().year -def ms_to_hms(milliseconds): +def ms_to_hms(milliseconds: int): seconds = int((milliseconds / 1000) % 60) minutes = int(((milliseconds / (1000*60)) % 60)) hours = int(((milliseconds / (1000*60*60)) % 24)) return {"hours": hours, "minutes": minutes, "seconds": seconds} -async def _get_stats_year(client, year): +async def _get_stats_year(client: AsyncClient, year: int) -> dict: stats_year = {} stats = await client.get( "stats/aggregates", @@ -50,7 +51,7 @@ async def _get_stats_year(client, year): help="start year for collecting listening stats" ) @pass_client -async def cli(client, output, signup_year): +async def cli(client: AsyncClient, output: pathlib.Path, signup_year: int): """get and analyse listening statistics""" year_range = [y for y in range(signup_year, current_year+1)] diff --git a/src/audible_cli/cli.py b/src/audible_cli/cli.py index 4bb75aa..dfc76b3 100644 --- a/src/audible_cli/cli.py +++ b/src/audible_cli/cli.py @@ -40,7 +40,7 @@ def cli(): @click.pass_context @version_option @verbosity_option(cli_logger=logger) -def quickstart(ctx): +def quickstart(ctx: click.Context): """Entrypoint for the quickstart command""" try: sys.exit(ctx.forward(cmd_quickstart.cli)) diff --git a/src/audible_cli/cmds/cmd_activation_bytes.py b/src/audible_cli/cmds/cmd_activation_bytes.py index c03fd15..653610a 100644 --- a/src/audible_cli/cmds/cmd_activation_bytes.py +++ b/src/audible_cli/cmds/cmd_activation_bytes.py @@ -6,6 +6,7 @@ fetch_activation_sign_auth ) +from ..config import Session from ..decorators import pass_session @@ -18,7 +19,7 @@ is_flag=True, help="Reload activation bytes and save to auth file.") @pass_session -def cli(session, **options): +def cli(session: Session, **options): """Get activation bytes.""" auth = session.auth if auth.activation_bytes is None or options.get("reload"): diff --git a/src/audible_cli/cmds/cmd_api.py b/src/audible_cli/cmds/cmd_api.py index 95cfc9c..751bdc8 100644 --- a/src/audible_cli/cmds/cmd_api.py +++ b/src/audible_cli/cmds/cmd_api.py @@ -6,6 +6,7 @@ import click from audible import Client +from ..config import Session from ..decorators import pass_session @@ -61,7 +62,7 @@ "the current profile is used." ) @pass_session -def cli(session, **options): +def cli(session: Session, **options): """Send requests to an Audible API endpoint Take a look at diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index 10fe248..efe7803 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -10,9 +10,11 @@ import click import httpx import questionary +from audible import AsyncClient from audible.exceptions import NotFoundError from click import echo +from ..config import Session from ..decorators import ( bunch_size_option, end_date_option, @@ -28,7 +30,7 @@ NotDownloadableAsAAX, VoucherNeedRefresh ) -from ..models import Library +from ..models import Library, LibraryItem from ..utils import datetime_type, Downloader @@ -180,7 +182,11 @@ async def download_cover( async def download_pdf( - client, output_dir, base_filename, item, overwrite_existing + client: AsyncClient, + output_dir: str, + base_filename: str, + item: LibraryItem, + overwrite_existing: bool, ): url = item.get_pdf_url() if url is None: @@ -200,8 +206,12 @@ async def download_pdf( async def download_chapters( - output_dir, base_filename, item, quality, overwrite_existing -): + output_dir: str, + base_filename: str, + item: LibraryItem, + quality: str, + overwrite_existing: bool, +) -> bool | None: if not output_dir.is_dir(): raise DirectoryDoesNotExists(output_dir) @@ -228,8 +238,11 @@ async def download_chapters( async def download_annotations( - output_dir, base_filename, item, overwrite_existing -): + output_dir: str, + base_filename: str, + item: LibraryItem, + overwrite_existing: bool, +) -> bool | None: if not output_dir.is_dir(): raise DirectoryDoesNotExists(output_dir) @@ -256,8 +269,13 @@ async def download_annotations( async def download_aax( - client, output_dir, base_filename, item, quality, overwrite_existing, - aax_fallback + client: AsyncClient, + output_dir: str, + base_filename: str, + item: LibraryItem, + quality: str, + overwrite_existing: bool, + aax_fallback: bool, ): # url, codec = await item.get_aax_url(quality) try: @@ -658,7 +676,7 @@ def display_counter(): @bunch_size_option @pass_session @pass_client(headers=CLIENT_HEADERS) -async def cli(session, api_client, **params): +async def cli(session: Session, api_client: AsyncClient, **params): """download audiobook(s) from library""" client = api_client.session output_dir = pathlib.Path(params.get("output_dir")).resolve() diff --git a/src/audible_cli/cmds/cmd_library.py b/src/audible_cli/cmds/cmd_library.py index b38699f..df1ec2e 100644 --- a/src/audible_cli/cmds/cmd_library.py +++ b/src/audible_cli/cmds/cmd_library.py @@ -3,8 +3,10 @@ import pathlib import click +from audible import AsyncClient from click import echo +from ..config import Session from ..decorators import ( bunch_size_option, end_date_option, @@ -23,7 +25,11 @@ def cli(): """interact with library""" -async def _get_library(session, client, resolve_podcasts): +async def _get_library( + session: Session, + client: AsyncClient, + resolve_podcasts: bool, +): bunch_size = session.params.get("bunch_size") start_date = session.params.get("start_date") end_date = session.params.get("end_date") @@ -76,11 +82,11 @@ async def _get_library(session, client, resolve_podcasts): @end_date_option @pass_session @pass_client -async def export_library(session, client, **params): +async def export_library(session: Session, client: AsyncClient, **params): """export library""" @wrap_async - def _prepare_item(item): + def _prepare_item(item: dict): data_row = {} for key in item: v = getattr(item, key) @@ -164,11 +170,11 @@ def _prepare_item(item): @end_date_option @pass_session @pass_client -async def list_library(session, client, resolve_podcasts): +async def list_library(session: Session, client: AsyncClient, resolve_podcasts: bool): """list titles in library""" @wrap_async - def _prepare_item(item): + def _prepare_item(item: dict): fields = [item.asin] authors = ", ".join( diff --git a/src/audible_cli/cmds/cmd_manage.py b/src/audible_cli/cmds/cmd_manage.py index 03e4259..99dd8cd 100644 --- a/src/audible_cli/cmds/cmd_manage.py +++ b/src/audible_cli/cmds/cmd_manage.py @@ -6,6 +6,7 @@ from click import echo, secho from tabulate import tabulate +from ..config import Session from ..decorators import pass_session from ..utils import build_auth_file @@ -35,14 +36,14 @@ def manage_auth_files(): @manage_config.command("edit") @pass_session -def config_editor(session): +def config_editor(session: Session): """Open the config file with default editor""" click.edit(filename=session.config.filename) @manage_profiles.command("list") @pass_session -def list_profiles(session): +def list_profiles(session: Session): """List all profiles in the config file""" head = ["P", "Profile", "auth file", "cc"] config = session.config @@ -89,7 +90,14 @@ def list_profiles(session): ) @pass_session @click.pass_context -def add_profile(ctx, session, profile, country_code, auth_file, is_primary): +def add_profile( + ctx: None, + session: Session, + profile: pathlib.Path, + country_code: str, + auth_file: pathlib.Path, + is_primary: bool, +): """Adds a profile to config file""" if not (session.config.dirname / auth_file).exists(): logger.error("Auth file doesn't exists") @@ -110,7 +118,7 @@ def add_profile(ctx, session, profile, country_code, auth_file, is_primary): help="The profile name to remove from config." ) @pass_session -def remove_profile(session, profile): +def remove_profile(session: Session, profile: dict): """Remove one or multiple profile(s) from config file""" profiles = session.config.data.get("profile") for p in profile: @@ -126,7 +134,12 @@ def remove_profile(session, profile): @pass_session -def check_if_auth_file_not_exists(session, ctx, param, value): +def check_if_auth_file_not_exists( + session: Session, + ctx: None, + param: None, + value: pathlib.Path, +): value = session.config.dirname / value if pathlib.Path(value).exists(): logger.error("The file already exists.") @@ -176,8 +189,14 @@ def check_if_auth_file_not_exists(session, ctx, param, value): ) @pass_session def add_auth_file( - session, auth_file, password, audible_username, - audible_password, country_code, external_login, with_username + session: Session, + auth_file: pathlib.Path, + password: str | None, + audible_username: str | None, + audible_password: str | None, + country_code: str, + external_login: bool = False, + with_username: bool = False, ): """Register a new device and add an auth file to config dir""" build_auth_file( @@ -192,7 +211,12 @@ def add_auth_file( @pass_session -def check_if_auth_file_exists(session, ctx, param, value): +def check_if_auth_file_exists( + session: Session, + ctx: None, + param: None, + value: pathlib.Path, +): value = session.config.dirname / value if not pathlib.Path(value).exists(): logger.error("The file doesn't exists.") @@ -212,7 +236,7 @@ def check_if_auth_file_exists(session, ctx, param, value): "--password", "-p", help="The optional password for the auth file." ) -def remove_auth_file(auth_file, password): +def remove_auth_file(auth_file: pathlib.Path, password: str | None): """Deregister a device and remove auth file from config dir""" auth = Authenticator.from_file(auth_file, password) device_name = auth.device_info["device_name"] diff --git a/src/audible_cli/cmds/cmd_quickstart.py b/src/audible_cli/cmds/cmd_quickstart.py index a5bd8d3..da57aaa 100644 --- a/src/audible_cli/cmds/cmd_quickstart.py +++ b/src/audible_cli/cmds/cmd_quickstart.py @@ -7,7 +7,7 @@ from tabulate import tabulate from .. import __version__ -from ..config import ConfigFile +from ..config import ConfigFile, Session from ..constants import CONFIG_FILE, DEFAULT_AUTH_FILE_EXTENSION from ..decorators import pass_session from ..utils import build_auth_file @@ -140,7 +140,7 @@ def ask_user(config: ConfigFile): @click.command("quickstart") @pass_session -def cli(session): +def cli(session: Session): """Quick setup audible""" config_file: pathlib.Path = session.app_dir / CONFIG_FILE config = ConfigFile(config_file, file_exists=False) diff --git a/src/audible_cli/cmds/cmd_wishlist.py b/src/audible_cli/cmds/cmd_wishlist.py index a4351d2..048805c 100644 --- a/src/audible_cli/cmds/cmd_wishlist.py +++ b/src/audible_cli/cmds/cmd_wishlist.py @@ -6,8 +6,10 @@ import click import httpx import questionary +from audible import AsyncClient from click import echo +from ..config import Session from ..decorators import timeout_option, pass_client, wrap_async from ..models import Catalog, Wishlist from ..utils import export_to_csv @@ -20,7 +22,7 @@ limits = httpx.Limits(max_keepalive_connections=1, max_connections=1) -async def _get_wishlist(client): +async def _get_wishlist(client: AsyncClient): wishlist = await Wishlist.from_api( client, response_groups=( @@ -55,11 +57,11 @@ def cli(): help="Output format" ) @pass_client -async def export_wishlist(client, **params): +async def export_wishlist(client: Session, **params): """export wishlist""" @wrap_async - def _prepare_item(item): + def _prepare_item(item: dict): data_row = {} for key in item: v = getattr(item, key) @@ -133,11 +135,11 @@ def _prepare_item(item): @cli.command("list") @timeout_option @pass_client -async def list_wishlist(client): +async def list_wishlist(client: AsyncClient): """list titles in wishlist""" @wrap_async - def _prepare_item(item): + def _prepare_item(item: dict): fields = [item.asin] authors = ", ".join( @@ -184,7 +186,7 @@ async def add_wishlist(client, asin, title): Run the command without any option for interactive mode. """ - async def add_asin(asin): + async def add_asin(asin: str): body = {"asin": asin} r = await client.post("wishlist", body=body) return r @@ -261,13 +263,13 @@ async def add_asin(asin): ) @timeout_option @pass_client(limits=limits) -async def remove_wishlist(client, asin, title): +async def remove_wishlist(client: AsyncClient, asin: str, title: str): """remove asin(s) from wishlist Run the command without any option for interactive mode. """ - async def remove_asin(rasin): + async def remove_asin(rasin: str): r = await client.delete(f"wishlist/{rasin}") item = wishlist.get_item_by_asin(rasin) logger.info(f"{rasin} ({item.full_title}) removed from wishlist") diff --git a/src/audible_cli/exceptions.py b/src/audible_cli/exceptions.py index de865cd..ad7299b 100644 --- a/src/audible_cli/exceptions.py +++ b/src/audible_cli/exceptions.py @@ -39,7 +39,7 @@ def __init__(self, path): class ProfileAlreadyExists(AudibleCliException): """Raised if an item is not found""" - def __init__(self, name): + def __init__(self, name: str): message = f"Profile {name} already exist" super().__init__(message) @@ -51,7 +51,7 @@ class LicenseDenied(AudibleCliException): class NoDownloadUrl(AudibleCliException): """Raised if a license response does not contain a download url""" - def __init__(self, asin): + def __init__(self, asin: str): message = f"License response for {asin} does not contain a download url" super().__init__(message) @@ -59,7 +59,7 @@ def __init__(self, asin): class DownloadUrlExpired(AudibleCliException): """Raised if a download url is expired""" - def __init__(self, lr_file): + def __init__(self, lr_file: str): message = f"Download url in {lr_file} is expired." super().__init__(message) @@ -67,7 +67,7 @@ def __init__(self, lr_file): class VoucherNeedRefresh(AudibleCliException): """Raised if a voucher reached his refresh date""" - def __init__(self, lr_file): + def __init__(self, lr_file: str): message = f"Refresh date for voucher {lr_file} reached." super().__init__(message) @@ -75,7 +75,7 @@ def __init__(self, lr_file): class ItemNotPublished(AudibleCliException): """Raised if a voucher reached his refresh date""" - def __init__(self, asin: str, pub_date): + def __init__(self, asin: str, pub_date: str): pub_date = datetime.strptime(pub_date, "%Y-%m-%dT%H:%M:%SZ") now = datetime.utcnow() published_in = pub_date - now diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index ddbdc32..4b07490 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -56,7 +56,7 @@ def _prepare_data(self, data: dict) -> dict: return data @property - def full_title(self): + def full_title(self) -> str: title: str = self.title if self.subtitle is not None: title = f"{title}: {self.subtitle}" @@ -67,7 +67,7 @@ def full_title(self): return title @property - def full_title_slugify(self): + def full_title_slugify(self) -> str: valid_chars = "-_.() " + string.ascii_letters + string.digits cleaned_title = unicodedata.normalize("NFKD", self.full_title or "") cleaned_title = cleaned_title.encode("ASCII", "ignore") @@ -81,7 +81,7 @@ def full_title_slugify(self): return slug_title - def create_base_filename(self, mode: str): + def create_base_filename(self, mode: str) -> str: supported_modes = ("ascii", "asin_ascii", "unicode", "asin_unicode") if mode not in supported_modes: raise AudibleCliException( @@ -102,21 +102,21 @@ def create_base_filename(self, mode: str): return base_filename - def substring_in_title_accuracy(self, substring): + def substring_in_title_accuracy(self, substring: str) -> int: match = LongestSubString(substring, self.full_title) return round(match.percentage, 2) - def substring_in_title(self, substring, p=100): + def substring_in_title(self, substring: str, p: int = 100) -> bool: accuracy = self.substring_in_title_accuracy(substring) return accuracy >= p - def get_cover_url(self, res: Union[str, int] = 500): + def get_cover_url(self, res: Union[str, int] = 500) -> str: images = self.product_images res = str(res) if images is not None and res in images: return images[res] - def get_pdf_url(self): + def get_pdf_url(self) -> str: if not self.is_published(): raise ItemNotPublished(self.asin, self.publication_datetime) @@ -124,13 +124,13 @@ def get_pdf_url(self): domain = self._client.auth.locale.domain return f"https://www.audible.{domain}/companion-file/{self.asin}" - def is_parent_podcast(self): + def is_parent_podcast(self) -> bool: if "content_delivery_type" in self and "content_type" in self: if (self.content_delivery_type in ("Periodical", "PodcastParent") or self.content_type == "Podcast") and self.has_children: return True - def is_published(self): + def is_published(self) -> bool: if self.publication_datetime is not None: pub_date = datetime.strptime( self.publication_datetime, "%Y-%m-%dT%H:%M:%SZ" @@ -143,7 +143,7 @@ class LibraryItem(BaseItem): def _prepare_data(self, data: dict) -> dict: return data.get("item", data) - def _get_codec(self, quality: str): + def _get_codec(self, quality: str) -> tuple[str | None, int | None]: """If quality is not ``best``, ensures the given quality is present in them codecs list. Otherwise, will find the best aax quality available """ @@ -227,7 +227,7 @@ async def get_child_items(self, **request_params) -> Optional["Library"]: return children - def is_downloadable(self): + def is_downloadable(self) -> bool: # customer_rights must be in response_groups if self.customer_rights is not None: if self.customer_rights["is_consumable_offline"]: @@ -436,16 +436,16 @@ def _prepare_data(self, data: Union[dict, list]) -> Union[dict, list]: def data(self): return self._data - def get_item_by_asin(self, asin): + def get_item_by_asin(self, asin: str) -> str | None: try: return next(i for i in self._data if asin == i.asin) except StopIteration: return None - def has_asin(self, asin): + def has_asin(self, asin: str) -> bool: return True if self.get_item_by_asin(asin) else False - def search_item_by_title(self, search_title, p=80): + def search_item_by_title(self, search_title: str, p: int = 80) -> list: match = [] for i in self._data: accuracy = i.substring_in_title_accuracy(search_title) diff --git a/src/audible_cli/plugins.py b/src/audible_cli/plugins.py index 3d370e3..e9a82d6 100644 --- a/src/audible_cli/plugins.py +++ b/src/audible_cli/plugins.py @@ -95,7 +95,7 @@ class provides a modified help message informing the user that the plugin the plugin loader encountered. """ - def __init__(self, name): + def __init__(self, name: str): """ Define the special help messages after instantiating a `click.Command()`. """ @@ -117,7 +117,7 @@ def __init__(self, name): icon + " Warning: could not load plugin. See `%s %s --help`." % (util_name, self.name)) - def invoke(self, ctx): + def invoke(self, ctx: click.Context): """ Print the traceback instead of doing nothing. @@ -126,5 +126,5 @@ def invoke(self, ctx): click.echo(self.help, color=ctx.color) ctx.exit(1) - def parse_args(self, ctx, args): + def parse_args(self, ctx: None, args: list[str]): return args diff --git a/src/audible_cli/utils.py b/src/audible_cli/utils.py index bc7f6e8..e9f5a77 100644 --- a/src/audible_cli/utils.py +++ b/src/audible_cli/utils.py @@ -10,7 +10,7 @@ import httpx import tqdm from PIL import Image -from audible import Authenticator +from audible import Authenticator, AsyncClient from audible.client import raise_for_status from audible.login import default_login_url_callback from click import echo, secho, prompt @@ -147,7 +147,7 @@ def percentage(self): return self._match.size / len(self._search_for) * 100 -def asin_in_library(asin, library): +def asin_in_library(asin: str, library): items = library.get("items") or library try: @@ -172,7 +172,7 @@ def __init__( self, url: Union[httpx.URL, str], file: Union[pathlib.Path, str], - client, + client: AsyncClient, overwrite_existing: bool, content_type: Optional[Union[List[str], str]] = None ) -> None: @@ -216,7 +216,13 @@ def _file_okay(self): return True - def _postpare(self, elapsed, status_code, length, content_type): + def _postpare( + self, + elapsed, + status_code: int, + length: int, + content_type: str, + ): if not 200 <= status_code < 400: try: msg = self._tmp_file.read_text() diff --git a/utils/update_chapter_titles.py b/utils/update_chapter_titles.py index ba6da27..56a4662 100644 --- a/utils/update_chapter_titles.py +++ b/utils/update_chapter_titles.py @@ -37,10 +37,11 @@ import click from click import echo +from io import BufferedWriter class ApiMeta: - def __init__(self, api_meta_file): + def __init__(self, api_meta_file: str): self._meta_raw = pathlib.Path(api_meta_file).read_text("utf-8") self._meta_parsed = self._parse_meta() @@ -71,7 +72,7 @@ class FFMeta: SECTION = re.compile(r"\[(?P
[^]]+)\]") OPTION = re.compile(r"(?P