From d6b35d455a300af8d02aafed2a2df39ba4760701 Mon Sep 17 00:00:00 2001 From: Travis Hathaway Date: Tue, 21 Mar 2023 16:47:38 +0100 Subject: [PATCH] Follow up changes to recent pull request merge These are a couple more changes that needed to be made before advancing to the next release. Mostly just updating the documentation. --- README.md | 52 ++++++-- docs/creating-plugins.md | 219 ++++++++++--------------------- latz/commands/config/commands.py | 21 ++- latz/constants.py | 3 - latz/plugins/hookspec.py | 14 +- pyproject.toml | 2 +- tests/commands/test_config.py | 8 +- 7 files changed, 133 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index c32c55f..47a7f54 100644 --- a/README.md +++ b/README.md @@ -53,17 +53,50 @@ $ cd latz $ pip install -e . ``` -### Configuring +### Usage -latz comes initially configured with the "unsplash" image search backend. To use this, +Latz comes initially configured with the "unsplash" image search backend. To use this, you will need to create an Unsplash account and create a test application. After getting -your "access_key", you will need to configure it by adding it to your `.latz.json` -config file. An example is show below: +your "access_key", you can set this value by running this command: + +```bash +$ latz config set search_backend_settings.unsplash.access_key= +``` + +Once this is configured, you can search Unsplash for bunny pictures: + +```bash +$ latz search "bunny" +[ + ImageSearchResultSet( + results=( + ImageSearchResult( + url='https://unsplash.com/photos/u_kMWN-BWyU/download?ixid=MnwzOTMwOTR8MHwxfHNlYXJjaHwxfHxidW5ueXxlbnwwfHx8fDE2Nzk0MTA2NzQ', + width=3456, + height=5184 + ), + # ... results truncated + ), + total_number_results=10, + search_backend='unsplash' + ) +] +``` + +### Configuring + +The configuration for latz is stored in your home direct and is in the JSON format. +Below is a what a default version of this configuration looks like: ```json { - "backend": "unsplash", - "backend_settings": { + "search_backends": [ + "unsplash" + ], + "search_backend_settings": { + "placeholder": { + "type": "kitten" + }, "unsplash": { "access_key": "your-access-key" } @@ -71,14 +104,11 @@ config file. An example is show below: } ``` -_This file must be stored in your home directory or your current working directory._ +_Latz will also search in your current working directory for a `.latz.json` file and use this in your configuration. +Files in the current working directory will be prioritized over your home directory location._ To see other available image search backends, see [Available image search backends](#available-image-search-backends) below. -### Usage - - - ### Available image search backends Here are a list of the available search backends: diff --git a/docs/creating-plugins.md b/docs/creating-plugins.md index 8b5ce1d..bdd7f27 100644 --- a/docs/creating-plugins.md +++ b/docs/creating-plugins.md @@ -8,12 +8,13 @@ [pydantic-dynamic-models]: https://docs.pydantic.dev/usage/models/#dynamic-model-creation [latz_imgur_main]: https://github.com/travishathaway/latz-imgur/blob/main/latz_imgur/main.py [python-protocol]: https://docs.python.org/ +[httpx-async-client]: https://www.python-httpx.org/api/#asyncclient -This is a guide that will show you how to create your own latz image API plugin. -To illustrate how to do this, we will write a plugin for the Imgur image search API. -We be starting from just an empty directory and using [poetry][poetry] packaging tool -to show how you can easily upload your plugin to PyPI once we are finished. +This guide will show you how to create your own latz search backend hook. +These search backend hooks allow you to add additional image search APIs to latz. +Once complete, you will be able to use these new search backends with the `latz search` +command. Check out [latz-imgur on GitHub][latz-imgur] if you would like to skip ahead and browse the final working example. @@ -24,8 +25,8 @@ To follow along, you will need to create an Imgur account and register an applic via their web interface. Once complete, save the `client_id` you receive as we will be using that for this application. Head over to [their documentation][imgur-docs] for more information. -For managing dependencies and publishing to PyPI, we use the tool [poetry][poetry]. Please -install and configure this if you do not currently have it on your computer. +For managing dependencies and to make it easier to publish to PyPI later, we use the tool +[poetry][poetry]. Please install and configure this if you do not currently have it on your computer. ## Setting up our environment @@ -96,213 +97,131 @@ class ImgurBackendConfig(BaseModel): """ access_key: str = Field(description="Access key for the Imgur API") - - -# Module level constant declaring all configuration settings for this plugin -CONFIG_FIELDS = { - PLUGIN_NAME: (ImgurBackendConfig, {"access_key": ""}) -} ``` - - !!! note - Latz uses this `CONFIG_FIELDS` dictionary to dynamically generate its own `AppConfig` + Latz uses this `ImgurBackendConfig` model to dynamically generate its own `AppConfig` model at runtime. Check out [Dynamic model creation][pydantic-dynamic-models] in the [pydantic docs][pydantic] to learn more. -### Image search API +### Search backend hook function Now that our plugin is able to gather the configuration settings necessary to run (i.e. the -"access_key" we get from Imgur), we are ready to write the actual search API code. Latz requires -us to first write a class a that implements the [protocol][python-protocol] class -[`ImageAPI`][latz.image.ImageAPI]. -The only thing that this protocol requires us to do is define a `search` method which returns the -`ImageSearchResultSet` type. Furthermore, the `ImageSearchResultSet` type must be instantiated -with a sequence of `ImageSearchResult` . +"access_key" we get from Imgur), we are ready to write the actual search API code. To make this +work, we need to define an async search function that returns an `ImageSearchResultSet`. +Latz will pass an instance of the [httpx.AsyncClient][httpx-async-client], the application +configuration and the search query to this function for us. -Tying all of these requirements together, below is an example of what this class could look like: +Below is an example of what this could look like: !!! note Click on the tool tips in the code to learn more :thinking: :books: ```python title="latz_imgur/main.py" - import urllib.parse -from typing import Any import httpx -from latz.image import ImageSearchResult, ImageSearchResultSet - +from latz.exceptions import SearchBackendError +from latz.image import ImageSearchResultSet, ImageSearchResult #: Base URL for the Imgur API BASE_URL = "https://api.imgur.com/3/" #: Endpoint used for searching images -SEARCH_ENDPOINT = "gallery/search" +SEARCH_ENDPOINT = urllib.parse.urljoin(BASE_URL, "gallery/search") -class ImgurImageAPI: # (1) +async def search(client, config, query: str) -> ImageSearchResultSet: # (1) """ - Implementation of ImageAPI for use with the Imgur API: - https://apidocs.imgur.com/ + Search hook that will be invoked by latz while invoking the "search" command """ + client.headers = httpx.Headers({ + "Authorization": f"Client-ID {config.search_backend_settings.imgur.access_key}" + }) + json_data = await _get(client, SEARCH_ENDPOINT, query) - def __init__(self, client: httpx.Client): - """ - Attach a `httpx.Client` object to our API - """ - self._client = client - - @staticmethod - def _get_image_search_result_record( - record_image: dict[str, Any] - ) -> ImageSearchResult: # (2) - """ - Helper method used to create `ImageSearchResult` objects - """ - return ImageSearchResult( + search_results = tuple( + ImageSearchResult( # (2) url=record_image.get("link"), width=record_image.get("width"), height=record_image.get("height") ) + for record in json_data.get("data", tuple()) + for record_image in record.get("images", tuple()) + ) - def search(self, query: str) -> ImageSearchResultSet: # (3) - """ - Find images based on a `query` and return an `ImageSearchResultSet` - """ - search_url = urllib.parse.urljoin(BASE_URL, SEARCH_ENDPOINT) - - resp = self._client.get(search_url, params={"q": query}) - resp.raise_for_status() - - json_data = resp.json() - - search_results = tuple( - self._get_image_search_result_record(record_image) - for record in json_data.get("data", tuple()) - for record_image in record.get("images", tuple()) - ) - - return ImageSearchResultSet( - search_results, len(json_data.get("data", tuple())) - ) -``` - -1. The API class defined here accepts a `httpx.Client` object so that it can query - the Imgur API. To properly function as a latz plugin, this class must also define a - `search` method (see below), which is specified by the latz [`ImageAPI`][latz.image.ImageAPI] - protocol. -2. [`ImageSearchResult`][latz.image.ImageSearchResult] is a special type defined by latz. - Using this type helps ensure the result you return will be properly rendered. -3. Here, we implement the `search` method required by the [`ImageAPI`][latz.image.ImageAPI] - protocol. [`ImageSearchResultSet`][latz.image.ImageSearchResultSet] is a type defined - by latz to help organize results returned by the [`ImageAPI`][latz.image.ImageAPI] classes. - - -### Image API context manager - -We now have an `ImgurImageAPI` class that is capable of querying the Imgur API and returning -the types of results that latz needs. You will notice that this class accepts a `httpx.Client` -object which it uses to make the actual HTTP requests. We now need to write the code that -will instantiate this object and pass it into the `ImgurImageAPI` class. - -To do this, we create a context manager. This context manager will be registered with latz -itself and this is how the application will make a new connection objects and run queries. -Below, is an implementation of this context manager using Python's `contextlib` module: - -```python title="latz_imgur/main.py" -from contextlib import contextmanager -from typing import Iterator - -import httpx - + return ImageSearchResultSet( + search_results, len(search_results), search_backend=PLUGIN_NAME + ) -@contextmanager -def imgur_context_manager(config) -> Iterator[ImgurImageAPI]: # (1) +async def _get(client: httpx.AsyncClient, url: str, query: str) -> dict: """ - Context manager that returns the `ImgurImageAPI` we wish to use + Wraps `client.get` call in a try, except so that we raise + an application specific exception instead. - This specific context manager handles setting up and tearing down the `httpx.Client` - connection that we use in this plugin. + :raises SearchBackendError: Encountered during problems querying the API """ - client = httpx.Client() - client.headers = httpx.Headers({ # (2) - "Authorization": f"Client-ID {config.backend_settings.imgur.access_key}" - }) - try: - yield ImgurImageAPI(client) - finally: - client.close() # (3) -``` + resp = await client.get(url, params={"query": query}) + resp.raise_for_status() + except httpx.HTTPError as exc: + raise SearchBackendError(str(exc), original=exc) -1. All image API context managers will receive a `config` object holding applicable settings for - the configured image API backend. This context manager must also yield an instantiated object - that implements the [`ImageAPI`][latz.image.ImageAPI]. -2. These are the headers that the Imgur API expects. We are able to retrieve the `client_id` from - `config` object that is pass into this function. -3. We use context managers so that we can perform any clean up actions necessary + json_data = resp.json() + if not isinstance(json_data, dict): + raise SearchBackendError("Received malformed response from search backend") -!!! note - **Why use a context manager?** - - Using a context manager allows plugin authors to use libraries which made need to perform clean - up actions on connections made. This is not only important - for the `httpx` library but could come in handy if we ever decide to implement a plugin using a - database connections. Python's also `contextlib.contextmanager` decorator makes these fairly - simple to define, reducing the complexity for plugin authors. + return json_data +``` +1. The arguments passed to this function give you everything you need to make a search + request. The `client` is a [httpx.AsyncClient][httpx-async-client], the `config` object + is the application configuration and the `query` string is the search string passed in + from the command line. +2. [`ImageSearchResult`][latz.image.ImageSearchResult] is a special type defined by latz. + Using this type helps ensure the result you return will be properly rendered. ### Registering everything with latz We are now at the final step: registering everything we have written with latz. To do this, we need to use the `latz.plugins.hookimpl` decorator to register our plugins. We do this -by decorating a function called `image_api` that returns a `ImageAPIPlugin` type. The -`ImageAPIPlugin` type is an object which has three fields: +by decorating a function called `search_backend` that returns a `SearchBackendHook` object. +The `SearchBackendHook` object is an object which has three fields: -- `name`: name of the plugin that users will use to specify it their configuraiton -- `image_api_context_manager`: context manager that returns the [`ImageAPI`][latz.image.ImageAPI] - class that we defined.t -- `config_fields`: config fields that we defined in the first step. This what allows latz to - register these settings and make them available to users. +- `name`: name of the plugin that users will use to specify it their configuration +- `search`: async function that will be called to search for images +- `config_fields`: Pydantic model representing the config fields we want to expose in the + application Here is what this function looks like: ```python title="latz_imgur/main.py" -from latz.plugins import hookimpl, ImageAPIPlugin - +from latz.plugins import hookimpl, SearchBackendHook @hookimpl -def image_api(): +def search_backend(): """ Registers our Imgur image API backend """ - return ImageAPIPlugin( + return SearchBackendHook( name=PLUGIN_NAME, - image_api_context_manager=imgur_context_manager, - config_fields=CONFIG_FIELDS, + search=search, + config_fields=ImgurBackendConfig(access_key=""), ) ``` ## Wrapping up -In this guide, we showed how to create a latz image API plugin. The most important steps +In this guide, we showed how to create a latz search backend hook. The most important steps were: 1. Creating our configuration fields, so we can allow users of the plugin to define necessary access tokens -2. Creating the actual `ImgurImageAPI` object which implemented the [`ImageAPI`][latz.image.ImageAPI] - protocol. -3. Creating the image API context manager for creating our HTTP client and `ImgurImageAPI` object -4. Tying everything together by creating an `image_api` function decorated by the `latz.plugins.hookimpl`. - This function's only responsibility is to return an `ImageAPIPlugin` object that combines everything - we have written in this module so far. - -When adapting this code to write future plugins, it is important to realize that you may not -always have to define configuration settings (perhaps your API is completely open). But, the things -that will remain constant is the [`ImageAPI`][latz.image.ImageAPI] protocol. This protocol is a contract -between your plugin and latz, and both parties must adhere to it for a smooth ride :sunglasses: 🚗. - -Happy plugin writing ✌️ +2. Creating the `search` function which returns an [`ImageSearchResultSet`][latz.image.ImageSearchResultSet] + object. +3. Tying everything together by creating an `search_backend` function decorated by `latz.plugins.hookimpl`. + This function's only responsibility is to return an [`SearchBackendHook`][latz.plugins.hookspec.SearchBackendHook] + object that combines everything we have written in this module so far. + +Thanks for following along and happy plugin writing ✌️ diff --git a/latz/commands/config/commands.py b/latz/commands/config/commands.py index d21063e..c8ca773 100644 --- a/latz/commands/config/commands.py +++ b/latz/commands/config/commands.py @@ -1,9 +1,11 @@ from __future__ import annotations +from pathlib import Path + import rich_click as click from rich import print as rprint -from ...constants import CONFIG_FILE_CWD, CONFIG_FILE_HOME_DIR +from ...constants import CONFIG_FILE_HOME_DIR from ...config import parse_config_file_as_json, write_config_file from ...exceptions import ConfigError from .validators import ConfigValuesValidator @@ -27,16 +29,21 @@ def show_command(ctx): @click.command("set") @click.argument("config_values", nargs=-1, callback=validate_and_parse_config_values) @click.option( - "-h", - "--home", - is_flag=True, - help="Write to home directory config file instead of in current working directory", + "-c", + "--config", + type=click.Path(exists=True), + help="Path of config file to write to. Defaults to ~/.latz.json", ) -def set_command(home, config_values): +def set_command(config, config_values): """ Set configuration values. """ - config_file = CONFIG_FILE_HOME_DIR if home else CONFIG_FILE_CWD + config_file = Path(config or CONFIG_FILE_HOME_DIR) + + # If this file does not exist, write an empty JSON object to it + if not config_file.exists(): + with config_file.open("w") as fp: + fp.write("{}") parsed_config = parse_config_file_as_json(config_file) diff --git a/latz/constants.py b/latz/constants.py index 6a8c24f..f16d618 100644 --- a/latz/constants.py +++ b/latz/constants.py @@ -9,13 +9,10 @@ CONFIG_FILE_NAME = ".latz.json" -CONFIG_FILE_CWD = Path(os.getcwd()) / CONFIG_FILE_NAME - CONFIG_FILE_HOME_DIR = Path(os.path.expanduser("~")) / CONFIG_FILE_NAME #: Config files to be loaded. Order will be respected, which means that #: the config file on the bottom will override locations on the top. CONFIG_FILES = ( CONFIG_FILE_HOME_DIR, - CONFIG_FILE_CWD, ) diff --git a/latz/plugins/hookspec.py b/latz/plugins/hookspec.py index 26df4a0..09ba2f9 100644 --- a/latz/plugins/hookspec.py +++ b/latz/plugins/hookspec.py @@ -26,11 +26,11 @@ class SearchBackendHook(NamedTuple): **Example:** ```python - from latz.plugins import ImageAPIPlugin + from latz.plugins import SearchBackendHook @hookimpl - def image_api(): - return ImageSearchHook( + def search_backend(): + return SearchBackendHook( name="custom", ... ) @@ -71,17 +71,11 @@ def image_api(): class CustomConfigFields(BaseModel): access_key: str = Field(description="Access key for the API") - CONFIG_FIELDS = { - PLUGIN_NAME: ( - CustomConfigFields, {"access_key": ""} - ) - } - @hookimpl def image_api(): return ImageAPIPlugin( name=PLUGIN_NAME, - config_fields=CONFIG_FIELDS, + config_fields=CustomConfigFields(access_key=""), ... ) ``` diff --git a/pyproject.toml b/pyproject.toml index 0ecefd9..3cabe84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "latz" -version = "0.1.5" +version = "0.2.0" description = "CLI Program for downloading images. Maybe by location too..." authors = ["Travis Hathaway "] license = "GNU v3" diff --git a/tests/commands/test_config.py b/tests/commands/test_config.py index ddd1a7c..9ce49fc 100644 --- a/tests/commands/test_config.py +++ b/tests/commands/test_config.py @@ -39,7 +39,7 @@ def test_set_search_backends(runner: tuple[CliRunner, Path], mocker): Makes sure that we can update the "search_backends" via the "config set" subcommand """ cmd_runner, config_file = runner - mocker.patch("latz.commands.config.commands.CONFIG_FILE_CWD", config_file) + mocker.patch("latz.commands.config.commands.CONFIG_FILE_HOME_DIR", config_file) result = cmd_runner.invoke(cli, [COMMAND, "set", "search_backends=unsplash"]) assert result.stdout == "" @@ -78,7 +78,7 @@ def test_set_search_backend_settings( Makes sure that we can update the "search_backend_settings" via the "config set" subcommand """ cmd_runner, config_file = runner - mocker.patch("latz.commands.config.commands.CONFIG_FILE_CWD", config_file) + mocker.patch("latz.commands.config.commands.CONFIG_FILE_HOME_DIR", config_file) result = cmd_runner.invoke(cli, [COMMAND, "set", f"{parameter}={expected}"]) assert result.stdout == "" @@ -102,7 +102,7 @@ def test_set_bad_config_backend(runner: tuple[CliRunner, Path], mocker): Test the case when we try to pass in a bad value for "backend". """ cmd_runner, config_file = runner - mocker.patch("latz.commands.config.commands.CONFIG_FILE_CWD", config_file) + mocker.patch("latz.commands.config.commands.CONFIG_FILE_HOME_DIR", config_file) result = cmd_runner.invoke(cli, [COMMAND, "set", "backend=bad_value"]) assert result.exit_code == 2 @@ -122,7 +122,7 @@ def test_set_config_backend_with_bad_config_file( TODO: Break this into two tests """ cmd_runner, config_file = runner - mocker.patch("latz.commands.config.commands.CONFIG_FILE_CWD", config_file) + mocker.patch("latz.commands.config.commands.CONFIG_FILE_HOME_DIR", config_file) with config_file.open("w") as fp: fp.write("bad val")