From 6cb6d7f0e12af76cecc0b46d309c0a1064d98de8 Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Mon, 10 Oct 2022 15:12:23 -0700 Subject: [PATCH 01/18] _-prefix methods that should not be overridden --- .../provider_data_ingester.py | 25 +++++++++++-------- .../test_provider_data_ingester.py | 6 ++--- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/openverse_catalog/dags/providers/provider_api_scripts/provider_data_ingester.py b/openverse_catalog/dags/providers/provider_api_scripts/provider_data_ingester.py index 7028e0c4c..541bb884a 100644 --- a/openverse_catalog/dags/providers/provider_api_scripts/provider_data_ingester.py +++ b/openverse_catalog/dags/providers/provider_api_scripts/provider_data_ingester.py @@ -105,7 +105,7 @@ def __init__(self, conf: dict = None, date: str = None): self.delayed_requester = DelayedRequester( delay=self.delay, headers=self.headers ) - self.media_stores = self.init_media_stores() + self.media_stores = self._init_media_stores() self.date = date # dag_run configuration options @@ -126,7 +126,7 @@ def __init__(self, conf: dict = None, date: str = None): # Create a generator to facilitate fetching the next set of query_params. self.override_query_params = (qp for qp in query_params_list) - def init_media_stores(self) -> dict[str, MediaStore]: + def _init_media_stores(self) -> dict[str, MediaStore]: """ Initialize a media store for each media type supported by this provider. @@ -153,7 +153,7 @@ def ingest_records(self, **kwargs) -> None: logger.info(f"Begin ingestion for {self.__class__.__name__}") while should_continue: - query_params = self.get_query_params(query_params, **kwargs) + query_params = self._get_query_params(query_params, **kwargs) if query_params is None: # Break out of ingestion if no query_params are supplied. This can # happen when the final `override_query_params` is processed. @@ -175,7 +175,7 @@ def ingest_records(self, **kwargs) -> None: # If errors have already been caught during processing, raise them # as well. - if error_summary := self.get_ingestion_errors(): + if error_summary := self._get_ingestion_errors(): raise error_summary from error raise @@ -192,7 +192,7 @@ def ingest_records(self, **kwargs) -> None: # Commit whatever records we were able to process, and rethrow the # exception so the taskrun fails. - self.commit_records() + self._commit_records() raise error from ingestion_error if self.limit and record_count >= self.limit: @@ -200,13 +200,13 @@ def ingest_records(self, **kwargs) -> None: should_continue = False # Commit whatever records we were able to process - self.commit_records() + self._commit_records() # If errors were caught during processing, raise them now - if error_summary := self.get_ingestion_errors(): + if error_summary := self._get_ingestion_errors(): raise error_summary - def get_ingestion_errors(self) -> AggregateIngestionError | None: + def _get_ingestion_errors(self) -> AggregateIngestionError | None: """ If any errors were skipped during ingestion, log them as well as the associated query parameters. Then return an AggregateIngestionError. @@ -235,10 +235,13 @@ def get_ingestion_errors(self) -> AggregateIngestionError | None: ) return None - def get_query_params(self, prev_query_params: dict | None, **kwargs) -> dict | None: + def _get_query_params( + self, prev_query_params: dict | None, **kwargs + ) -> dict | None: """ Returns the next set of query_params for the next request, handling - optional overrides via the dag_run conf. + optional overrides via the dag_run conf. This method should not be overridden; + instead override get_next_query_params. """ # If we are getting query_params for the first batch and initial_query_params # have been set, return them. @@ -391,7 +394,7 @@ def get_record_data(self, data: dict) -> dict | list[dict] | None: """ pass - def commit_records(self) -> int: + def _commit_records(self) -> int: total = 0 for store in self.media_stores.values(): total += store.commit() diff --git a/tests/dags/providers/provider_api_scripts/test_provider_data_ingester.py b/tests/dags/providers/provider_api_scripts/test_provider_data_ingester.py index 7da644683..e6a6d0fa3 100644 --- a/tests/dags/providers/provider_api_scripts/test_provider_data_ingester.py +++ b/tests/dags/providers/provider_api_scripts/test_provider_data_ingester.py @@ -133,7 +133,7 @@ def test_ingest_records(): with ( patch.object(ingester, "get_batch") as get_batch_mock, patch.object(ingester, "process_batch", return_value=3) as process_batch_mock, - patch.object(ingester, "commit_records") as commit_mock, + patch.object(ingester, "_commit_records") as commit_mock, ): get_batch_mock.side_effect = [ (EXPECTED_BATCH_DATA, True), # First batch @@ -231,7 +231,7 @@ def test_ingest_records_commits_on_exception(): with ( patch.object(ingester, "get_batch") as get_batch_mock, patch.object(ingester, "process_batch", return_value=3) as process_batch_mock, - patch.object(ingester, "commit_records") as commit_mock, + patch.object(ingester, "_commit_records") as commit_mock, ): get_batch_mock.side_effect = [ (EXPECTED_BATCH_DATA, True), # First batch @@ -384,7 +384,7 @@ def test_commit_commits_all_stores(): patch.object(audio_store, "commit") as audio_store_mock, patch.object(image_store, "commit") as image_store_mock, ): - ingester.commit_records() + ingester._commit_records() assert audio_store_mock.called assert image_store_mock.called From a507cd7c1a4bbb2d8f2bc18f981b0fa6260e265b Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Mon, 10 Oct 2022 15:16:00 -0700 Subject: [PATCH 02/18] Initial template --- .../templates/template_provider.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 openverse_catalog/templates/template_provider.py diff --git a/openverse_catalog/templates/template_provider.py b/openverse_catalog/templates/template_provider.py new file mode 100644 index 000000000..3830b0c31 --- /dev/null +++ b/openverse_catalog/templates/template_provider.py @@ -0,0 +1,93 @@ +""" +A minimal template for a `ProviderDataIngester` subclass, which implements the +bare minimum of variables and methods. +""" +import logging + +from common import constants +from common.license import get_license_info +from common.loader import provider_details as prov +from providers.provider_api_scripts.provider_data_ingester import ProviderDataIngester + + +logger = logging.getLogger(__name__) + + +class MyProviderDataIngester(ProviderDataIngester): + providers = { + "image": prov.TEMPLATE_IMAGE_PROVIDER, + } + endpoint = "https://my-api-endpoint" + + def get_next_query_params(self, prev_query_params: dict | None, **kwargs) -> dict: + # On the first request, `prev_query_params` will be `None`. We can detect this + # and return our default params + if not prev_query_params: + return {"limit": self.batch_limit, "cc": 1, "offset": 0} + else: + # Example case where the offset is incremented for each subsequent request. + return { + **prev_query_params, + "offset": prev_query_params["offset"] + self.batch_limit, + } + + def get_batch_data(self, response_json): + # Takes the raw API response from calling `get` on the endpoint, and returns + # the list of records to process. + if response_json: + return response_json.get("results") + return None + + def get_media_type(self, record: dict): + # For a given record json, return the media type it represents. May be + # hard-coded if the provider only returns records of one type. + return constants.IMAGE + + def get_record_data(self, data: dict) -> dict | list[dict] | None: + # Parse out the necessary info from the record data into a dictionary. + + # If a required field is missing, return early to prevent unnecesary + # processing. + if (foreign_landing_url := data.get("url")) is None: + return None + if (image_url := data.get("image_url")) is None: + return None + + # Use the common get_license_info method to get license information + license_url = data.get("license") + license_info = get_license_info(license_url) + if license_info is None: + return None + + # This example assumes the API guarantees all fields. Note that other + # media types may have different fields available. + return { + "foreign_landing_url": foreign_landing_url, + "image_url": image_url, + "license_info": license_info, + # Optional fields + "foreign_identifier": data.get("foreign_id"), + "thumbnail_url": data.get("thumbnail"), + "filesize": data.get("filesize"), + "filetype": data.get("filetype"), + "creator": data.get("creator"), + "creator_url": data.get("creator_url"), + "title": data.get("title"), + "meta_data": data.get("meta_data"), + "raw_tags": data.get("tags"), + "watermarked": data.get("watermarked"), + "width": data.get("width"), + "height": data.get("height"), + } + + +def main(): + # Allows running ingestion from the CLI without Airflow running for debugging + # purposes. + logger.info("Begin: MyProvider data ingestion") + ingester = MyProviderDataIngester() + ingester.ingest_records() + + +if __name__ == "__main__": + main() From 98fef3c2934ce6ee6859a9c595202097d2a29f88 Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Mon, 10 Oct 2022 15:16:37 -0700 Subject: [PATCH 03/18] Add initial docs --- .../docs/adding-a-new-provider.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 openverse_catalog/docs/adding-a-new-provider.md diff --git a/openverse_catalog/docs/adding-a-new-provider.md b/openverse_catalog/docs/adding-a-new-provider.md new file mode 100644 index 000000000..1c4c1a7c2 --- /dev/null +++ b/openverse_catalog/docs/adding-a-new-provider.md @@ -0,0 +1,275 @@ +# How to Add a New Provider to Openverse + +The Openverse Catalog collects data from the APIs of sites that share openly-licensed media,and saves them in our Catalog database. We call the scripts that pull data from these APIs "Provider API scripts". You can find examples in [`provider_api_scripts` folder](../dags/providers/provider_api_scripts). + +To add a new Provider to Openverse, you'll need to do the following: +1. *Add a `provider` string to the [common.loader.provider_details](../dags/common/loader/provider_details.py) module.* This is the string that will be used to populate the `provider` column in the database for records ingested by your provider. + ``` + MY_PROVIDER_DEFAULT_PROVIDER = "myprovider" + ``` +2. *Create a new module in the [`provider_api_scripts` folder](../dags/providers/provider_api_scripts).* This is what we call the "provider script", and it's responsible for actually pulling the data from the API and committing it locally. Much of this logic is implemented in the [`ProviderDataIngester` base class](../dags/providers/provider_api_scripts/provider_data_ingester.py): at a high level, all you need to do is subclass the ProviderDataIngester and fill out a handful of abstract methods. You can copy and edit the [`TemplateProviderDataIngester`](../template_provider.py) to meet your needs, or read more detailed instructions in the [section below](#implementing-a-providerdataingester). + ``` + # In provider_api_scripts/myprovider.py + + from providers.provider_api_scripts.provider_data_ingester import ProviderDataIngester + + + class MyProviderDataIngester(ProviderDataIngester): + ... + ``` +3. *Define a `ProviderWorkflow` configuration in order to generate a DAG.* We use Airflow DAGs (link to Airflow docs) to automate data ingestion. All you need to do to create a DAG for your provider is to define a `ProviderWorkflow` dataclass and add it to the `PROVIDER_WORKFLOWS` list in [`provider_workflows.py`](../dags/providers/provider_workflows.py). Our DAG factories will pick up the configuration and generate a new DAG in Airflow! + + At minimum, you'll need to provide the following in your configuration: + * `provider_script`: the name of the file where you defined your `ProviderDataIngester` class + * `ingestion_callable`: the `ProviderDataIngester` class + * `media_types`: the media types your provider handles + ``` + ProviderWorkflow( + provider_script='myprovider', + ingestion_callable=MyProviderDataIngester, + media_types=("image", "audio",) + + ) + ``` + There are many other options that allow you to tweak the `schedule` (when and how often your DAG is run), timeouts for individual steps of the DAG, and more. See the documentation for details. + + +You should now have a fully functioning provider DAG. *NOTE*: when your code is merged, the DAG will become available in production but will be disabled by default. A contributor with Airflow access will need to manually turn the DAG on in production. + +# Implementing a ProviderDataIngester + +Implementing a ProviderDataIngester class is the bulk of the work for adding a new Provider to Openverse. Luckily, it's pretty simple! You'll need to subclass [`ProviderDataIngester`](../dags/providers/provider_api_scripts/provider_data_ingester.py), which takes care of much of the work for you, including all of the logic for actually making requests and iterating over batches. At a high level, the `ProviderDataIngester` iteratively: + +* Builds the next set of `query_parameters` +* Makes a `GET` request to the configured API endpoint, and receives a batch of data +* Iterates over each item in the batch and extracts just the data we want to store +* Commits these records to an appropriate `MediaStore` + +From there, the `load` steps of the generated provider DAG handle loading the records into the actual Catalog database. + +The base class also automatically adds support for some extra features that may be useful for testing and debugging. + +In the simplest case, all you need to do is implement the abstact methods on your child class. For more advanced options see [below](#advanced-options). + +## Define Class variables + +### `providers` + +This is a dictionary mapping each media type your provider supports to their corresponding `provider` string (the string that will populate the `provider` field in the DB). These strings +should be defined as constants in [common.loader.provider_details.py](../dags/common/loader/provider_details.py). + +By convention, when a provider supports multiple media types we set separate provider strings for each type. For example, `wikimedia` and `wikimedia_audio`. + +``` +from common.loader import provider_details as prov + +providers = { + "image": prov.MYPROVIDER_DEFAULT_PROVIDER, + "audio": prov.MYPROVIDER_AUDIO_PROVIDER +} +``` + +### `endpoint` + +This is the main API endpoint from which batches of data will be requested. The ProviderDataIngester assumes that the same endpoint will be hit each batch, with different query params. If you have a more complicated use case, you may find help in the following sections: +* [Implementing a computed endpoint with variable path](#endpoint) +* [Overriding get_response_json to make a more complex request](#get_response_json) + + +### `batch_limit` + +The number of records to retrieve in a given batch. _Default: 100_ + +### `delay` + +Integer number of seconds to wait between consecutive requests to the `endpoint`. You may need to increase this to respect certain rate limits. _Default: 1_ + +### `retries` + +Integer number of times to retry requests to `endpoint` on error. _Default: 3_ + +### `headers` + +Dictionary of headers passed to `GET` requests. _Default: {}_ + +## Implement Required Methods + +### `get_next_query_params` + +Given the set of query params used in the previous request, generate the query params to be used for the next request. Commonly, this is used to increment an `offset` or `page` parameter between requests. + +``` +def get_next_query_params(self, prev_query_params, **kwargs): + if not prev_query_params: + # On the first request, `prev_query_params` is None. Return default params + return { + "api_key": Variable.get("API_KEY_MYPROVIDER") + "limit": self.batch_limit, + "skip": 0 + } + else: + # Update only the parameters that require changes on subsequent requests + return { + **prev_query_params, + "skip": prev_query_params["skip"] + self.batch_limit, + } +``` + +TIPS: +* `batch_limit` is not added automatically to the params, so if your API needs it make sure to add it yourself. +* If you need to pass an API key, set is an Airflow variable rather than hardcoding it. + +### `get_media_type` + +For a given record, return its media type. If your provider only returns one type of media, this may be hardcoded. + +``` +def get_media_type(self, record): + # This provider only supports Images. + return constants.IMAGE +``` + +### `get_record_data` + +This is the bulk of the work for a `ProviderDataIngester`. This method takes the json representation of a single record from your provider API, and returns a dictionary of all the necessary data. + +``` +def get_record_data(self, data): + # Return early if required fields are not present + if (foreign_landing_url := data.get('landing_url')) is None: + return None + + if (image_url := data.get('url')) is None: + return None + + if (license_url := data.get('license)) is None: + return None + license_info = get_license_info(license_url=license_url) + + ... + + return { + "foreign_landing_url": foreign_landing_url, + "image_url": image_url, + "license_info": license_info, + ... + } +``` + +TIPS: +* `get_record_data` may also return a `List` of dictionaries, if it is possible to extract multiple records from `data`. For example, this may be useful if a record contains information about associated/related records. +* Sometimes you may need to make additional API requests for individual records to acquire all the necessary data. You can use `self.get_response_json` to do so, by passing in a different `endpoint` argument. +* When adding items to the `meta_data` JSONB column, avoid storing any keys that have a value of `None` - this provides little value for Elasticsearch and bloats the space required to store the record. +* If a required field is missing, immediately return None to skip further processing of the record as it will be discarded regardless. + + + +*Required Fields* +| field name | description | +| --- | --- | +| *foreign_landing_url* | URL of page where the record lives on the source website. | +| *audio_url* / *image_url* | Direct link to the media file. Note that the field name differs depending on media type. | +| *license_info* | LicenseInfo object that has (1) the URL of the license for the record, (2) string representation of the license, (3) version of the license, (4) raw license URL that was by provider, if different from canonical URL | + +To get the LicenseInfo object, use `common.license.get_license_info` with either (`license_` and `license_version`) or `license_url` named parameters. In the case of the `publicdomain` license, which has no version, one should pass `common.license.constants.NO_VERSION` here. + +The following fields are optional, but it is highly encouraged to populate as much data as possible: + +| field name | description | +| --- | --- | +| *foreign_identifier* | Unique identifier for the record on the source site. | +| *thumbnail_url* | Direct link to a thumbnail-sized version of the record. | +| *filesize* | Size of the main file in bytes. | +| *filetype* | The filetype of the main file, eg. 'mp3', 'jpg', etc. | +| *creator* | The creator of the image. | +| *creator_url* | The user page, or home page of the creator. | +| *title* | Title of the record. | +| *meta_data* | Dictionary of metadata about the record. Currently, a key we prefer to have is `description`. | +| *raw_tags* | List of tags associated with the record. | +| *watermarked* | Boolean, true if the record has a watermark. | + +#### Image-specific fields + +Image also has the following fields: + +| field_name | description | +| --- | --- | +| *width* | Image width in pixels. | +| *height* | Image height in pixels. | + +#### Audio-specific fields + +Audio has the following fields: + +| field_name | description | +| --- | --- | +| *duration* | Audio duration in milliseconds. | +| *bit_rate* | Audio bit rate as int. | +| *sample_rate* | Audio sample rate as int. | +| *category* | Category such as 'music', 'sound', 'audio_book', or 'podcast'. | +| *genres* | List of genres. | +| *set_foreign_id* | Unique identifier for the audio set on the source site. | +| *audio_set* | The name of the set (album, pack, etc) the audio is part of. | +| *set_position* | Position of the audio in the audio_set. | +| *set_thumbnail* | URL of the audio_set thumbnail. | +| *set_url* | URL of the audio_set. | +| *alt_files* | A dictionary with information about alternative files for the audio (different formats/quality). Dict should have the following keys: url, filesize, bit_rate, sample_rate. + +## Advanced Options + +Some provider APIs may not neatly fit into this ingestion model. It's possible to further extend the `ProviderDataIngester` by overriding a few additional methods. + +### __init__ + +The `__init__` method may be overwritten in order to declare instance variables. When doing so, it is critical to call `super` and to pass through `kwargs`. Failing to do so may break fundamental behavior of the class. + +``` +def __init__(self, *args, **kwargs): + # Accept and pass through args/kwargs + super().__init__(*args, **kwargs) + + self.my_instance_variable = None +``` + +### endpoint + +Sometimes the `endpoint` may need to be computed, rather than declared statically. This may happen when the endpoint itself changes from one request to the next, rather than passing all required data through query params. In this case, you may implement `endpoint` as a `property`. + +``` +@property +def endpoint(self) -> str: + # In this example, the endpoint path includes an instance variable that may be modified + # by other parts of the code + return f"{BASE_ENDPOINT}/{self.my_instance_variable}" +``` + +### ingest_records + +This is the main ingestion function. It accepts optional `kwargs`, which it passes through on each call to `get_next_query_params`. This makes it possible to override `ingest_records` in order to iterate through a discrete set of query params for which you'd like to run ingestion. This is best demonstrated with an example: + +``` +CATEGORIES = ['music', 'audio_book', 'podcast'] + +def ingest_records(self, **kwargs): + for category in CATEGORIES: + super().ingest_records(category=category) + +def get_next_query_params(self, prev_query_params, **kwargs): + # Our param will be passed in to kwargs, and can be used to + # build the query params + category = kwargs.get("category") + return { + "category": category, + ... + } +``` + +### get_response_json + +This is the function that actually makes the `GET` requests to your endpoint. If a single `GET` request isn't sufficient, you may override this. For example, [Wikimedia](../dags/common/providers/provider_api_scripts/wikimedia_commons.py) makes multiple requests until it finds `batchcomplete`. + +### get_should_continue + +Each time `get_batch` is called, this method is called with the result of the batch. If it returns `False`, ingestion is halted immediately. + +This can be used to halt ingestion early based on some condition, for example the presence of a particular token in the response. By default it always returns `True`, and processing is only halted when no more data is available (or an error is encountered). From 277ecab7fe0a2049555f4ce0a6253d1decdccc2a Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Tue, 11 Oct 2022 15:39:46 -0700 Subject: [PATCH 04/18] Update template, add test template file --- .../templates/template_provider.py_template | 145 ++++++++++++++++++ .../dags/templates/template_test.py_template | 68 ++++++++ .../templates/template_provider.py | 93 ----------- 3 files changed, 213 insertions(+), 93 deletions(-) create mode 100644 openverse_catalog/dags/templates/template_provider.py_template create mode 100644 openverse_catalog/dags/templates/template_test.py_template delete mode 100644 openverse_catalog/templates/template_provider.py diff --git a/openverse_catalog/dags/templates/template_provider.py_template b/openverse_catalog/dags/templates/template_provider.py_template new file mode 100644 index 000000000..1e1ed96f6 --- /dev/null +++ b/openverse_catalog/dags/templates/template_provider.py_template @@ -0,0 +1,145 @@ +import logging + +from common import constants +from common.license import get_license_info +from common.loader import provider_details as prov +from providers.provider_api_scripts.provider_data_ingester import ProviderDataIngester + + +logger = logging.getLogger(__name__) + + +class {provider}DataIngester(ProviderDataIngester): + """ + This is a template for a ProviderDataIngester. + + Methods are shown with example implementations. Adjust them to suit your API. + """ + + # TODO: Add the provider constants to `common.loader.provider_details.py` + providers = { + {provider_configuration} + } + endpoint = "{endpoint}" + # TODO The following are set to their default values. Remove them if the defaults + # are acceptible, or override them. + delay = 1 + retries = 3 + batch_limit = 100 + headers = {} + + def get_next_query_params(self, prev_query_params: dict | None, **kwargs) -> dict: + # On the first request, `prev_query_params` will be `None`. We can detect this + # and return our default params + if not prev_query_params: + # TODO: Return your default params here. + # TODO: If you need an API key, add the (empty) key to `openverse_catalog/env.template` + # Do not hardcode API keys! + return { + "limit": self.batch_limit, + "cc": 1, + "offset": 0, + "api_key": Variable.get("API_KEY_{screaming_snake_provider}") + } + else: + # TODO: Update any query params that change on subsequent requests. + # Example case shows the offset being incremented by batch limit. + return { + **prev_query_params, + "offset": prev_query_params["offset"] + self.batch_limit, + } + + def get_batch_data(self, response_json): + # Takes the raw API response from calling `get` on the endpoint, and returns + # the list of records to process. + # TODO: Update based on your API. + if response_json: + return response_json.get("results") + return None + + def get_media_type(self, record: dict): + # For a given record json, return the media type it represents. + # TODO: Update based on your API. TIP: May be hard-coded if the provider only + # returns records of one type. + return record['media_type'] + + def get_record_data(self, data: dict) -> dict | list[dict] | None: + # Parse out the necessary info from the record data into a dictionary. + # TODO: Update based on your API. + + # REQUIRED FIELDS: + # - foreign_landing_url + # - license_info + # - image_url / audio_url + # + # If a required field is missing, return early to prevent unnecesary + # processing. + + if (foreign_landing_url := data.get("url")) is None: + return None + + # TODO: Note the url field name differs depending on field type. Append + # `image_url` or `audio_url` depending on the type of record being processed. + if (image_url := data.get("image_url")) is None: + return None + + # Use the `get_license_info` utility to get license information from a URL. + license_url = data.get("license") + license_info = get_license_info(license_url) + if license_info is None: + return None + + # OPTIONAL FIELDS + # Obtain as many optional fields as possible. + foreign_identifier = data.get("foreign_id") + thumbnail_url = data.get("thumbnail") + filesize = data.get("filesize") + filetype = data.get("filetype") + creator = data.get("creator") + creator_url = data.get("creator_url") + title = data.get("title") + meta_data = data.get("meta_data") + raw_tags = data.get("tags") + watermarked = data.get("watermarked") + + # MEDIA TYPE-SPECIFIC FIELDS + # Each Media type may also have its own optional fields. See documentation. + # TODO: Populate media type-specific fields. + # If your provider supports more than one media type, you'll need to first + # determine the media type of the record being processed. + # + # Example: + # media_type = self.get_media_type(data) + # media_type_specific_fields = self.get_media_specific_fields(media_type, data) + # + # If only one media type is supported, simply extract the fields here. + + return { + "foreign_landing_url": foreign_landing_url, + "image_url": image_url, + "license_info": license_info, + # Optional fields + "foreign_identifier": foreign_identifier, + "thumbnail_url": thumbnail_url, + "filesize": filesize, + "filetype": filetype, + "creator": creator, + "creator_url": creator_url, + "title": title, + "meta_data": meta_data, + "raw_tags": raw_tags, + "watermarked": watermarked, + # TODO: Remember to add any media-type specific fields here + } + + +def main(): + # Allows running ingestion from the CLI without Airflow running for debugging + # purposes. + logger.info("Begin: {provider} data ingestion") + ingester = {provider}DataIngester() + ingester.ingest_records() + + +if __name__ == "__main__": + main() diff --git a/openverse_catalog/dags/templates/template_test.py_template b/openverse_catalog/dags/templates/template_test.py_template new file mode 100644 index 000000000..12ad474b5 --- /dev/null +++ b/openverse_catalog/dags/templates/template_test.py_template @@ -0,0 +1,68 @@ +""" +TODO: Add additional tests for any methods you added in your subclass. +Try to test edge cases (missing keys, different data types returned, Nones, etc). +You may also need to update the given test names to be more specific. + +Run your tests locally with `just test -k {provider_underscore}` +""" + +import json +import logging +from pathlib import Path + +import pytest +from providers.provider_api_scripts.{provider_underscore} import {provider_data_ingester} + +# TODO: API responses used for testing can be added to this directory +RESOURCES = Path(__file__).parent / 'tests/resources/{provider_underscore}' + +# Set up test class +ingester = {provider_data_ingester}() + + +def test_get_next_query_params_default_response(): + actual_result = ingester.get_next_query_params(None) + expected_result = { + # TODO: Fill out expected default query params + } + assert actual_result == expected_result + + +def test_get_next_query_params_updates_parameters(): + previous_query_params = { + # TODO: Fill out a realistic set of previous query params + } + actual_result = ingester.get_next_query_params(previous_query_params) + + expected_result = { + # TODO: Fill out what the next set of query params should be, + # incrementing offsets or page numbers if necessary + } + assert actual_result == expected_result + + +def test_get_media_type(): + # TODO: Test the correct media type is returned for each possible media type. + pass + + +def test_get_record_data(): + # High level test for `get_record_data`. One way to test this is to create a + # `tests/resources/{provider}/single_item.json` file containing a sample json + # representation of a record from the API under test, call `get_record_data` with + # the json, and directly compare to expected output. + # + # Make sure to add additional tests for records of each media type supported by + # your provider. + + # Sample code for loading in the sample json + with open(RESOURCES / "single_item.json") as f: + resource_json = json.load(f) + + actual_data = ingester.get_record_data(resource_json) + + expected_data = { + # TODO: Fill out the expected data which will be saved to the Catalog + } + + assert actual_data == expected_data diff --git a/openverse_catalog/templates/template_provider.py b/openverse_catalog/templates/template_provider.py deleted file mode 100644 index 3830b0c31..000000000 --- a/openverse_catalog/templates/template_provider.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -A minimal template for a `ProviderDataIngester` subclass, which implements the -bare minimum of variables and methods. -""" -import logging - -from common import constants -from common.license import get_license_info -from common.loader import provider_details as prov -from providers.provider_api_scripts.provider_data_ingester import ProviderDataIngester - - -logger = logging.getLogger(__name__) - - -class MyProviderDataIngester(ProviderDataIngester): - providers = { - "image": prov.TEMPLATE_IMAGE_PROVIDER, - } - endpoint = "https://my-api-endpoint" - - def get_next_query_params(self, prev_query_params: dict | None, **kwargs) -> dict: - # On the first request, `prev_query_params` will be `None`. We can detect this - # and return our default params - if not prev_query_params: - return {"limit": self.batch_limit, "cc": 1, "offset": 0} - else: - # Example case where the offset is incremented for each subsequent request. - return { - **prev_query_params, - "offset": prev_query_params["offset"] + self.batch_limit, - } - - def get_batch_data(self, response_json): - # Takes the raw API response from calling `get` on the endpoint, and returns - # the list of records to process. - if response_json: - return response_json.get("results") - return None - - def get_media_type(self, record: dict): - # For a given record json, return the media type it represents. May be - # hard-coded if the provider only returns records of one type. - return constants.IMAGE - - def get_record_data(self, data: dict) -> dict | list[dict] | None: - # Parse out the necessary info from the record data into a dictionary. - - # If a required field is missing, return early to prevent unnecesary - # processing. - if (foreign_landing_url := data.get("url")) is None: - return None - if (image_url := data.get("image_url")) is None: - return None - - # Use the common get_license_info method to get license information - license_url = data.get("license") - license_info = get_license_info(license_url) - if license_info is None: - return None - - # This example assumes the API guarantees all fields. Note that other - # media types may have different fields available. - return { - "foreign_landing_url": foreign_landing_url, - "image_url": image_url, - "license_info": license_info, - # Optional fields - "foreign_identifier": data.get("foreign_id"), - "thumbnail_url": data.get("thumbnail"), - "filesize": data.get("filesize"), - "filetype": data.get("filetype"), - "creator": data.get("creator"), - "creator_url": data.get("creator_url"), - "title": data.get("title"), - "meta_data": data.get("meta_data"), - "raw_tags": data.get("tags"), - "watermarked": data.get("watermarked"), - "width": data.get("width"), - "height": data.get("height"), - } - - -def main(): - # Allows running ingestion from the CLI without Airflow running for debugging - # purposes. - logger.info("Begin: MyProvider data ingestion") - ingester = MyProviderDataIngester() - ingester.ingest_records() - - -if __name__ == "__main__": - main() From 6e5fd0482c6ad960d3a7e4cd6eda93afd582850e Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Tue, 11 Oct 2022 15:43:15 -0700 Subject: [PATCH 05/18] Add script to generate template files --- .../templates/create_provider_ingester.py | 154 ++++++++++++++++++ .../test_create_provider_ingester.py | 38 +++++ 2 files changed, 192 insertions(+) create mode 100644 openverse_catalog/dags/templates/create_provider_ingester.py create mode 100644 tests/dags/templates/test_create_provider_ingester.py diff --git a/openverse_catalog/dags/templates/create_provider_ingester.py b/openverse_catalog/dags/templates/create_provider_ingester.py new file mode 100644 index 000000000..7fc6164ed --- /dev/null +++ b/openverse_catalog/dags/templates/create_provider_ingester.py @@ -0,0 +1,154 @@ +""" +Script used to generate a templated ProviderDataIngester. +""" + +import argparse +from pathlib import Path + +import inflection + + +TEMPLATES_PATH = Path(__file__).parent +REPO_PATH = TEMPLATES_PATH.parents[2] +PROJECT_PATH = REPO_PATH.parent +MEDIA_TYPES = ["audio", "image"] + + +def _render_provider_configuration(provider: str, media_type: str): + """ + Render the provider configuration string for a particular media type. + """ + return f'"{media_type}": prov.{provider}_{media_type.upper()}_PROVIDER,' + + +def _get_filled_template( + template_path: Path, provider: str, endpoint: str, media_types: list[str] +): + with template_path.open("r", encoding="utf-8") as template: + camel_provider = inflection.camelize(provider) + screaming_snake_provider = inflection.underscore(provider).upper() + + # Build provider configuration + provider_configuration = "\n ".join( + _render_provider_configuration(screaming_snake_provider, media_type) + for media_type in media_types + ) + + template_string = template.read() + script_string = ( + template_string.replace("{provider}", camel_provider) + .replace("{screaming_snake_provider}", screaming_snake_provider) + .replace("{provider_underscore}", inflection.underscore(provider)) + .replace("{provider_data_ingester}", f"{camel_provider}DataIngester") + .replace("{endpoint}", endpoint) + .replace("{provider_configuration}", provider_configuration) + ) + + return script_string + + +def _render_file( + target: Path, + template_path: Path, + provider: str, + endpoint: str, + media_types: list[str], + name: str, +): + with target.open("w", encoding="utf-8") as target_file: + filled_template = _get_filled_template( + template_path, provider, endpoint, media_types + ) + target_file.write(filled_template) + print(f"{name + ':':<18} {target.relative_to(PROJECT_PATH)}") + + +def fill_template(provider, endpoint, media_types): + print(f"Creating files in {REPO_PATH}") + provider = provider.replace(" ", "_") + + dags_path = TEMPLATES_PATH.parent / "providers" + api_path = dags_path / "provider_api_scripts" + filename = inflection.underscore(provider) + + # Render the API file itself + script_template_path = TEMPLATES_PATH / "template_provider.py_template" + api_script_path = api_path / f"{filename}.py" + _render_file( + api_script_path, + script_template_path, + provider, + endpoint, + media_types, + "API script", + ) + + # Render the tests + script_template_path = TEMPLATES_PATH / "template_test.py_template" + tests_path = REPO_PATH / "tests" + # Mirror the directory structure, but under the "tests" top level directory + test_script_path = tests_path.joinpath(*api_path.parts[-3:]) / f"test_{filename}.py" + _render_file( + test_script_path, + script_template_path, + provider, + endpoint, + media_types, + "API script test", + ) + + print( + """ +NOTE: You will also need to add a new ProviderWorkflow dataclass configuration to the \ +PROVIDER_WORKFLOWS list in `openverse-catalog/dags/providers/provider_workflows.py`. +""" + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Create a new provider API ProviderDataIngester", + add_help=True, + ) + parser.add_argument( + "provider", help='Create the ingester for this provider (eg. "Cleveland").' + ) + parser.add_argument( + "-e", + "--endpoint", + required=True, + help="API endpoint to fetch data from" + '(eg. "https://commons.wikimedia.org/w/api.php").', + ) + parser.add_argument( + "-m", + "--media", + type=str, + nargs="*", + help="Ingester will collect media of these types" + " ('audio'/'image'). Default value is ['image',]", + ) + args = parser.parse_args() + provider = args.provider + endpoint = args.endpoint + + # Get valid media types + media_types = [] + for media_type in args.media: + if media_type in MEDIA_TYPES: + media_types.append(media_type) + else: + print(f"Ignoring invalid type {media_type}") + + # Default to image if no valid media types given + if not media_types: + print('No media type given, defaulting to ["image",]') + media_types = [ + "image", + ] + + fill_template(provider, endpoint, media_types) + + +if __name__ == "__main__": + main() diff --git a/tests/dags/templates/test_create_provider_ingester.py b/tests/dags/templates/test_create_provider_ingester.py new file mode 100644 index 000000000..095f29fbe --- /dev/null +++ b/tests/dags/templates/test_create_provider_ingester.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest + +from openverse_catalog.dags.templates import create_provider_ingester + + +@pytest.mark.parametrize( + "provider", + [ + ("Foobar Industries"), + ("FOOBAR INDUSTRIES"), + ("Foobar_industries"), + ("FoobarIndustries"), + ("foobar industries"), + ("foobar_industries"), + ], +) +def test_files_created(provider): + endpoint = "https://myfakeapi/v1" + media_type = "image" + dags_path = create_provider_ingester.TEMPLATES_PATH.parent / "providers" + expected_provider = dags_path / "provider_api_scripts" / "foobar_industries.py" + expected_test = ( + Path(__file__).parents[2] + / "dags" + / "providers" + / "provider_api_scripts" + / "test_foobar_industries.py" + ) + try: + create_provider_ingester.fill_template(provider, endpoint, media_type) + assert expected_provider.exists() + assert expected_test.exists() + finally: + # Clean up + expected_provider.unlink(missing_ok=True) + expected_test.unlink(missing_ok=True) From a20891333824ad2fab0f3877b2e829ae390753cd Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Tue, 11 Oct 2022 15:43:36 -0700 Subject: [PATCH 06/18] Update docs to reference script --- .../docs/adding-a-new-provider.md | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/openverse_catalog/docs/adding-a-new-provider.md b/openverse_catalog/docs/adding-a-new-provider.md index 1c4c1a7c2..c067c418c 100644 --- a/openverse_catalog/docs/adding-a-new-provider.md +++ b/openverse_catalog/docs/adding-a-new-provider.md @@ -3,21 +3,31 @@ The Openverse Catalog collects data from the APIs of sites that share openly-licensed media,and saves them in our Catalog database. We call the scripts that pull data from these APIs "Provider API scripts". You can find examples in [`provider_api_scripts` folder](../dags/providers/provider_api_scripts). To add a new Provider to Openverse, you'll need to do the following: -1. *Add a `provider` string to the [common.loader.provider_details](../dags/common/loader/provider_details.py) module.* This is the string that will be used to populate the `provider` column in the database for records ingested by your provider. - ``` - MY_PROVIDER_DEFAULT_PROVIDER = "myprovider" + +1. **Create a new ProviderDataIngester class.** Each provider should implement a subclass of the [`ProviderDataIngester` base class](../dags/providers/provider_api_scripts/provider_data_ingester.py), which is responsible for actually pulling the data from the API and committing it locally. Because much of this logic is implemented in the base class, all you need to do is fill out a handful of abstract methods. We provide a script +which can be used to generate the files you'll need and get you started. + + You'll need the `name` of your provider, the API `endpoint` to fetch data from, and the `media_types` + for which you expect to pull data (for example "image" or "audio"). ``` -2. *Create a new module in the [`provider_api_scripts` folder](../dags/providers/provider_api_scripts).* This is what we call the "provider script", and it's responsible for actually pulling the data from the API and committing it locally. Much of this logic is implemented in the [`ProviderDataIngester` base class](../dags/providers/provider_api_scripts/provider_data_ingester.py): at a high level, all you need to do is subclass the ProviderDataIngester and fill out a handful of abstract methods. You can copy and edit the [`TemplateProviderDataIngester`](../template_provider.py) to meet your needs, or read more detailed instructions in the [section below](#implementing-a-providerdataingester). + > python3 openverse_catalog/dags/templates/create_provider_data_ingester.py "Foo Museum" -e "https://foo-museum.org/api/v1/" -m image audio ``` - # In provider_api_scripts/myprovider.py - from providers.provider_api_scripts.provider_data_ingester import ProviderDataIngester + You should see output similar to this: + ``` + Creating files in /Users/staci/projects/openverse-projects/openverse-catalog + API script: openverse-catalog/openverse_catalog/dags/providers/provider_api_scripts/foo_museum.py + API script test: openverse-catalog/tests/dags/providers/provider_api_scripts/test_foo_museum.py + NOTE: You will also need to add a new ProviderWorkflow dataclass configuration to the PROVIDER_WORKFLOWS list in `openverse-catalog/dags/providers/provider_workflows.py`. + ``` - class MyProviderDataIngester(ProviderDataIngester): - ... + Complete the TODOs detailed in the generated files. For more detailed instructions and tips, read the documentation in the [section below](#implementing-a-providerdataingester). +2. **Add a `provider` string to the [common.loader.provider_details](../dags/common/loader/provider_details.py) module.** This is the string that will be used to populate the `provider` column in the database for records ingested by your provider. + ``` + FOO_MUSEUM_IMAGE_PROVIDER = "foo_museum" ``` -3. *Define a `ProviderWorkflow` configuration in order to generate a DAG.* We use Airflow DAGs (link to Airflow docs) to automate data ingestion. All you need to do to create a DAG for your provider is to define a `ProviderWorkflow` dataclass and add it to the `PROVIDER_WORKFLOWS` list in [`provider_workflows.py`](../dags/providers/provider_workflows.py). Our DAG factories will pick up the configuration and generate a new DAG in Airflow! +3. **Define a `ProviderWorkflow` configuration in order to generate a DAG.** We use Airflow DAGs (link to Airflow docs) to automate data ingestion. All you need to do to create a DAG for your provider is to define a `ProviderWorkflow` dataclass and add it to the `PROVIDER_WORKFLOWS` list in [`provider_workflows.py`](../dags/providers/provider_workflows.py). Our DAG factories will pick up the configuration and generate a new DAG in Airflow! At minimum, you'll need to provide the following in your configuration: * `provider_script`: the name of the file where you defined your `ProviderDataIngester` class @@ -25,8 +35,8 @@ To add a new Provider to Openverse, you'll need to do the following: * `media_types`: the media types your provider handles ``` ProviderWorkflow( - provider_script='myprovider', - ingestion_callable=MyProviderDataIngester, + provider_script='foo_museum', + ingestion_callable=FooMuseumDataIngester, media_types=("image", "audio",) ) From b073485c889452df822557d482f6d6acd0e5be95 Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Tue, 11 Oct 2022 16:02:39 -0700 Subject: [PATCH 07/18] Moving more documentation into the code --- .../provider_data_ingester.py | 16 ++++++++++++++-- openverse_catalog/docs/adding-a-new-provider.md | 12 +++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/openverse_catalog/dags/providers/provider_api_scripts/provider_data_ingester.py b/openverse_catalog/dags/providers/provider_api_scripts/provider_data_ingester.py index 541bb884a..222ca3a76 100644 --- a/openverse_catalog/dags/providers/provider_api_scripts/provider_data_ingester.py +++ b/openverse_catalog/dags/providers/provider_api_scripts/provider_data_ingester.py @@ -68,8 +68,20 @@ class ProviderDataIngester(ABC): @abstractmethod def providers(self) -> dict[str, str]: """ - A dictionary whose keys are the supported `media_types`, and values are - the `provider` string in the `media` table of the DB for that type. + A dictionary mapping each supported media type to its corresponding + `provider` string (the string that will populate the `provider` field + in the Catalog DB). These strings should be defined as constants in + common.loader.provider_details.py + + By convention, when a provider supports multiple media types we set + separate provider strings for each type. For example: + + ``` + providers = { + "image": provider_details.MYPROVIDER_IMAGE_PROVIDER, + "audio": provider_details.MYPROVIDER_AUDIO_PROVIDER, + } + ``` """ pass diff --git a/openverse_catalog/docs/adding-a-new-provider.md b/openverse_catalog/docs/adding-a-new-provider.md index c067c418c..98b94b014 100644 --- a/openverse_catalog/docs/adding-a-new-provider.md +++ b/openverse_catalog/docs/adding-a-new-provider.md @@ -41,10 +41,10 @@ which can be used to generate the files you'll need and get you started. ) ``` - There are many other options that allow you to tweak the `schedule` (when and how often your DAG is run), timeouts for individual steps of the DAG, and more. See the documentation for details. + There are many other options that allow you to tweak the `schedule` (when and how often your DAG is run), timeouts for individual steps of the DAG, and more. See the documentation for details. -You should now have a fully functioning provider DAG. *NOTE*: when your code is merged, the DAG will become available in production but will be disabled by default. A contributor with Airflow access will need to manually turn the DAG on in production. +You should now have a fully functioning provider DAG. *NOTE*: when your code is merged, the DAG will become available in production but will be disabled by default. A contributor with Airflow access will need to manually turn the DAG on in production. # Implementing a ProviderDataIngester @@ -74,15 +74,15 @@ By convention, when a provider supports multiple media types we set separate pro from common.loader import provider_details as prov providers = { - "image": prov.MYPROVIDER_DEFAULT_PROVIDER, - "audio": prov.MYPROVIDER_AUDIO_PROVIDER + "image": prov.FOO_MUSEUM_IMAGE_PROVIDER, + "audio": prov.FOO_MUSEUM_AUDIO_PROVIDER } ``` ### `endpoint` This is the main API endpoint from which batches of data will be requested. The ProviderDataIngester assumes that the same endpoint will be hit each batch, with different query params. If you have a more complicated use case, you may find help in the following sections: -* [Implementing a computed endpoint with variable path](#endpoint) +* [Implementing a computed endpoint with variable path](#endpoint-2) * [Overriding get_response_json to make a more complex request](#get_response_json) @@ -181,8 +181,6 @@ TIPS: | *audio_url* / *image_url* | Direct link to the media file. Note that the field name differs depending on media type. | | *license_info* | LicenseInfo object that has (1) the URL of the license for the record, (2) string representation of the license, (3) version of the license, (4) raw license URL that was by provider, if different from canonical URL | -To get the LicenseInfo object, use `common.license.get_license_info` with either (`license_` and `license_version`) or `license_url` named parameters. In the case of the `publicdomain` license, which has no version, one should pass `common.license.constants.NO_VERSION` here. - The following fields are optional, but it is highly encouraged to populate as much data as possible: | field name | description | From eeaebfd0cd226256993dec15f8e7ab9ce5f22a60 Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Wed, 12 Oct 2022 17:37:42 -0700 Subject: [PATCH 08/18] Reformat docs - Breaks out into several files - Removes documentation that is redundant (copied from code) - Prefers documentation within the template - Explicitly documents advanced options as FAQ - Some small updates to the templating --- .../templates/create_provider_ingester.py | 8 +- .../templates/template_provider.py_template | 7 +- .../docs/adding-a-new-provider.md | 283 ------------------ .../docs/adding_a_new_provider.md | 88 ++++++ .../assets/provider_dags/multi_media_dag.png | Bin 0 -> 75026 bytes .../docs/assets/provider_dags/simple_dag.png | Bin 0 -> 49722 bytes openverse_catalog/docs/data_models.md | 54 ++++ .../docs/provider_data_ingester_faq.md | 137 +++++++++ 8 files changed, 287 insertions(+), 290 deletions(-) delete mode 100644 openverse_catalog/docs/adding-a-new-provider.md create mode 100644 openverse_catalog/docs/adding_a_new_provider.md create mode 100644 openverse_catalog/docs/assets/provider_dags/multi_media_dag.png create mode 100644 openverse_catalog/docs/assets/provider_dags/simple_dag.png create mode 100644 openverse_catalog/docs/data_models.md create mode 100644 openverse_catalog/docs/provider_data_ingester_faq.md diff --git a/openverse_catalog/dags/templates/create_provider_ingester.py b/openverse_catalog/dags/templates/create_provider_ingester.py index 7fc6164ed..a5e6eec0a 100644 --- a/openverse_catalog/dags/templates/create_provider_ingester.py +++ b/openverse_catalog/dags/templates/create_provider_ingester.py @@ -111,14 +111,12 @@ def main(): add_help=True, ) parser.add_argument( - "provider", help='Create the ingester for this provider (eg. "Cleveland").' + "provider", help='Create the ingester for this provider (eg. "Wikimedia").' ) parser.add_argument( - "-e", - "--endpoint", - required=True, + "endpoint", help="API endpoint to fetch data from" - '(eg. "https://commons.wikimedia.org/w/api.php").', + ' (eg. "https://commons.wikimedia.org/w/api.php").', ) parser.add_argument( "-m", diff --git a/openverse_catalog/dags/templates/template_provider.py_template b/openverse_catalog/dags/templates/template_provider.py_template index 1e1ed96f6..722a32108 100644 --- a/openverse_catalog/dags/templates/template_provider.py_template +++ b/openverse_catalog/dags/templates/template_provider.py_template @@ -30,9 +30,10 @@ class {provider}DataIngester(ProviderDataIngester): def get_next_query_params(self, prev_query_params: dict | None, **kwargs) -> dict: # On the first request, `prev_query_params` will be `None`. We can detect this - # and return our default params + # and return our default params. if not prev_query_params: - # TODO: Return your default params here. + # TODO: Return your default params here. `batch_limit` is not automatically added to + # the params, so make sure to add it here if you need it! # TODO: If you need an API key, add the (empty) key to `openverse_catalog/env.template` # Do not hardcode API keys! return { @@ -66,6 +67,8 @@ class {provider}DataIngester(ProviderDataIngester): def get_record_data(self, data: dict) -> dict | list[dict] | None: # Parse out the necessary info from the record data into a dictionary. # TODO: Update based on your API. + # TODO: Important! Refer to the most up-to-date documentation about the + # available fields in `openverse_catalog/docs/data_models.md` # REQUIRED FIELDS: # - foreign_landing_url diff --git a/openverse_catalog/docs/adding-a-new-provider.md b/openverse_catalog/docs/adding-a-new-provider.md deleted file mode 100644 index 98b94b014..000000000 --- a/openverse_catalog/docs/adding-a-new-provider.md +++ /dev/null @@ -1,283 +0,0 @@ -# How to Add a New Provider to Openverse - -The Openverse Catalog collects data from the APIs of sites that share openly-licensed media,and saves them in our Catalog database. We call the scripts that pull data from these APIs "Provider API scripts". You can find examples in [`provider_api_scripts` folder](../dags/providers/provider_api_scripts). - -To add a new Provider to Openverse, you'll need to do the following: - -1. **Create a new ProviderDataIngester class.** Each provider should implement a subclass of the [`ProviderDataIngester` base class](../dags/providers/provider_api_scripts/provider_data_ingester.py), which is responsible for actually pulling the data from the API and committing it locally. Because much of this logic is implemented in the base class, all you need to do is fill out a handful of abstract methods. We provide a script -which can be used to generate the files you'll need and get you started. - - You'll need the `name` of your provider, the API `endpoint` to fetch data from, and the `media_types` - for which you expect to pull data (for example "image" or "audio"). - ``` - > python3 openverse_catalog/dags/templates/create_provider_data_ingester.py "Foo Museum" -e "https://foo-museum.org/api/v1/" -m image audio - ``` - - You should see output similar to this: - ``` - Creating files in /Users/staci/projects/openverse-projects/openverse-catalog - API script: openverse-catalog/openverse_catalog/dags/providers/provider_api_scripts/foo_museum.py - API script test: openverse-catalog/tests/dags/providers/provider_api_scripts/test_foo_museum.py - - NOTE: You will also need to add a new ProviderWorkflow dataclass configuration to the PROVIDER_WORKFLOWS list in `openverse-catalog/dags/providers/provider_workflows.py`. - ``` - - Complete the TODOs detailed in the generated files. For more detailed instructions and tips, read the documentation in the [section below](#implementing-a-providerdataingester). -2. **Add a `provider` string to the [common.loader.provider_details](../dags/common/loader/provider_details.py) module.** This is the string that will be used to populate the `provider` column in the database for records ingested by your provider. - ``` - FOO_MUSEUM_IMAGE_PROVIDER = "foo_museum" - ``` -3. **Define a `ProviderWorkflow` configuration in order to generate a DAG.** We use Airflow DAGs (link to Airflow docs) to automate data ingestion. All you need to do to create a DAG for your provider is to define a `ProviderWorkflow` dataclass and add it to the `PROVIDER_WORKFLOWS` list in [`provider_workflows.py`](../dags/providers/provider_workflows.py). Our DAG factories will pick up the configuration and generate a new DAG in Airflow! - - At minimum, you'll need to provide the following in your configuration: - * `provider_script`: the name of the file where you defined your `ProviderDataIngester` class - * `ingestion_callable`: the `ProviderDataIngester` class - * `media_types`: the media types your provider handles - ``` - ProviderWorkflow( - provider_script='foo_museum', - ingestion_callable=FooMuseumDataIngester, - media_types=("image", "audio",) - - ) - ``` - There are many other options that allow you to tweak the `schedule` (when and how often your DAG is run), timeouts for individual steps of the DAG, and more. See the documentation for details. - - -You should now have a fully functioning provider DAG. *NOTE*: when your code is merged, the DAG will become available in production but will be disabled by default. A contributor with Airflow access will need to manually turn the DAG on in production. - -# Implementing a ProviderDataIngester - -Implementing a ProviderDataIngester class is the bulk of the work for adding a new Provider to Openverse. Luckily, it's pretty simple! You'll need to subclass [`ProviderDataIngester`](../dags/providers/provider_api_scripts/provider_data_ingester.py), which takes care of much of the work for you, including all of the logic for actually making requests and iterating over batches. At a high level, the `ProviderDataIngester` iteratively: - -* Builds the next set of `query_parameters` -* Makes a `GET` request to the configured API endpoint, and receives a batch of data -* Iterates over each item in the batch and extracts just the data we want to store -* Commits these records to an appropriate `MediaStore` - -From there, the `load` steps of the generated provider DAG handle loading the records into the actual Catalog database. - -The base class also automatically adds support for some extra features that may be useful for testing and debugging. - -In the simplest case, all you need to do is implement the abstact methods on your child class. For more advanced options see [below](#advanced-options). - -## Define Class variables - -### `providers` - -This is a dictionary mapping each media type your provider supports to their corresponding `provider` string (the string that will populate the `provider` field in the DB). These strings -should be defined as constants in [common.loader.provider_details.py](../dags/common/loader/provider_details.py). - -By convention, when a provider supports multiple media types we set separate provider strings for each type. For example, `wikimedia` and `wikimedia_audio`. - -``` -from common.loader import provider_details as prov - -providers = { - "image": prov.FOO_MUSEUM_IMAGE_PROVIDER, - "audio": prov.FOO_MUSEUM_AUDIO_PROVIDER -} -``` - -### `endpoint` - -This is the main API endpoint from which batches of data will be requested. The ProviderDataIngester assumes that the same endpoint will be hit each batch, with different query params. If you have a more complicated use case, you may find help in the following sections: -* [Implementing a computed endpoint with variable path](#endpoint-2) -* [Overriding get_response_json to make a more complex request](#get_response_json) - - -### `batch_limit` - -The number of records to retrieve in a given batch. _Default: 100_ - -### `delay` - -Integer number of seconds to wait between consecutive requests to the `endpoint`. You may need to increase this to respect certain rate limits. _Default: 1_ - -### `retries` - -Integer number of times to retry requests to `endpoint` on error. _Default: 3_ - -### `headers` - -Dictionary of headers passed to `GET` requests. _Default: {}_ - -## Implement Required Methods - -### `get_next_query_params` - -Given the set of query params used in the previous request, generate the query params to be used for the next request. Commonly, this is used to increment an `offset` or `page` parameter between requests. - -``` -def get_next_query_params(self, prev_query_params, **kwargs): - if not prev_query_params: - # On the first request, `prev_query_params` is None. Return default params - return { - "api_key": Variable.get("API_KEY_MYPROVIDER") - "limit": self.batch_limit, - "skip": 0 - } - else: - # Update only the parameters that require changes on subsequent requests - return { - **prev_query_params, - "skip": prev_query_params["skip"] + self.batch_limit, - } -``` - -TIPS: -* `batch_limit` is not added automatically to the params, so if your API needs it make sure to add it yourself. -* If you need to pass an API key, set is an Airflow variable rather than hardcoding it. - -### `get_media_type` - -For a given record, return its media type. If your provider only returns one type of media, this may be hardcoded. - -``` -def get_media_type(self, record): - # This provider only supports Images. - return constants.IMAGE -``` - -### `get_record_data` - -This is the bulk of the work for a `ProviderDataIngester`. This method takes the json representation of a single record from your provider API, and returns a dictionary of all the necessary data. - -``` -def get_record_data(self, data): - # Return early if required fields are not present - if (foreign_landing_url := data.get('landing_url')) is None: - return None - - if (image_url := data.get('url')) is None: - return None - - if (license_url := data.get('license)) is None: - return None - license_info = get_license_info(license_url=license_url) - - ... - - return { - "foreign_landing_url": foreign_landing_url, - "image_url": image_url, - "license_info": license_info, - ... - } -``` - -TIPS: -* `get_record_data` may also return a `List` of dictionaries, if it is possible to extract multiple records from `data`. For example, this may be useful if a record contains information about associated/related records. -* Sometimes you may need to make additional API requests for individual records to acquire all the necessary data. You can use `self.get_response_json` to do so, by passing in a different `endpoint` argument. -* When adding items to the `meta_data` JSONB column, avoid storing any keys that have a value of `None` - this provides little value for Elasticsearch and bloats the space required to store the record. -* If a required field is missing, immediately return None to skip further processing of the record as it will be discarded regardless. - - - -*Required Fields* -| field name | description | -| --- | --- | -| *foreign_landing_url* | URL of page where the record lives on the source website. | -| *audio_url* / *image_url* | Direct link to the media file. Note that the field name differs depending on media type. | -| *license_info* | LicenseInfo object that has (1) the URL of the license for the record, (2) string representation of the license, (3) version of the license, (4) raw license URL that was by provider, if different from canonical URL | - -The following fields are optional, but it is highly encouraged to populate as much data as possible: - -| field name | description | -| --- | --- | -| *foreign_identifier* | Unique identifier for the record on the source site. | -| *thumbnail_url* | Direct link to a thumbnail-sized version of the record. | -| *filesize* | Size of the main file in bytes. | -| *filetype* | The filetype of the main file, eg. 'mp3', 'jpg', etc. | -| *creator* | The creator of the image. | -| *creator_url* | The user page, or home page of the creator. | -| *title* | Title of the record. | -| *meta_data* | Dictionary of metadata about the record. Currently, a key we prefer to have is `description`. | -| *raw_tags* | List of tags associated with the record. | -| *watermarked* | Boolean, true if the record has a watermark. | - -#### Image-specific fields - -Image also has the following fields: - -| field_name | description | -| --- | --- | -| *width* | Image width in pixels. | -| *height* | Image height in pixels. | - -#### Audio-specific fields - -Audio has the following fields: - -| field_name | description | -| --- | --- | -| *duration* | Audio duration in milliseconds. | -| *bit_rate* | Audio bit rate as int. | -| *sample_rate* | Audio sample rate as int. | -| *category* | Category such as 'music', 'sound', 'audio_book', or 'podcast'. | -| *genres* | List of genres. | -| *set_foreign_id* | Unique identifier for the audio set on the source site. | -| *audio_set* | The name of the set (album, pack, etc) the audio is part of. | -| *set_position* | Position of the audio in the audio_set. | -| *set_thumbnail* | URL of the audio_set thumbnail. | -| *set_url* | URL of the audio_set. | -| *alt_files* | A dictionary with information about alternative files for the audio (different formats/quality). Dict should have the following keys: url, filesize, bit_rate, sample_rate. - -## Advanced Options - -Some provider APIs may not neatly fit into this ingestion model. It's possible to further extend the `ProviderDataIngester` by overriding a few additional methods. - -### __init__ - -The `__init__` method may be overwritten in order to declare instance variables. When doing so, it is critical to call `super` and to pass through `kwargs`. Failing to do so may break fundamental behavior of the class. - -``` -def __init__(self, *args, **kwargs): - # Accept and pass through args/kwargs - super().__init__(*args, **kwargs) - - self.my_instance_variable = None -``` - -### endpoint - -Sometimes the `endpoint` may need to be computed, rather than declared statically. This may happen when the endpoint itself changes from one request to the next, rather than passing all required data through query params. In this case, you may implement `endpoint` as a `property`. - -``` -@property -def endpoint(self) -> str: - # In this example, the endpoint path includes an instance variable that may be modified - # by other parts of the code - return f"{BASE_ENDPOINT}/{self.my_instance_variable}" -``` - -### ingest_records - -This is the main ingestion function. It accepts optional `kwargs`, which it passes through on each call to `get_next_query_params`. This makes it possible to override `ingest_records` in order to iterate through a discrete set of query params for which you'd like to run ingestion. This is best demonstrated with an example: - -``` -CATEGORIES = ['music', 'audio_book', 'podcast'] - -def ingest_records(self, **kwargs): - for category in CATEGORIES: - super().ingest_records(category=category) - -def get_next_query_params(self, prev_query_params, **kwargs): - # Our param will be passed in to kwargs, and can be used to - # build the query params - category = kwargs.get("category") - return { - "category": category, - ... - } -``` - -### get_response_json - -This is the function that actually makes the `GET` requests to your endpoint. If a single `GET` request isn't sufficient, you may override this. For example, [Wikimedia](../dags/common/providers/provider_api_scripts/wikimedia_commons.py) makes multiple requests until it finds `batchcomplete`. - -### get_should_continue - -Each time `get_batch` is called, this method is called with the result of the batch. If it returns `False`, ingestion is halted immediately. - -This can be used to halt ingestion early based on some condition, for example the presence of a particular token in the response. By default it always returns `True`, and processing is only halted when no more data is available (or an error is encountered). diff --git a/openverse_catalog/docs/adding_a_new_provider.md b/openverse_catalog/docs/adding_a_new_provider.md new file mode 100644 index 000000000..e88250430 --- /dev/null +++ b/openverse_catalog/docs/adding_a_new_provider.md @@ -0,0 +1,88 @@ +# Openverse Provider DAGs + +The Openverse Catalog collects data from the APIs of sites that share openly-licensed media,and saves them in our Catalog database. This process is automated by Airflow DAGs generated for each provider. A simple provider DAG looks like this: + +![Example DAG](assets/provider_dags/simple_dag.png) + +At a high level the steps are: + +1. `generate_filename`: Generates a TSV filename used in later steps +2. `pull_data`: Actually pulls records from the provider API, collects just the data we need, and commits it to local storage in TSVs. +3. `load_data`: Loads the data from TSVs into the actual Catalog database, updating old records and discarding duplicates. +4. `report_load_completion`: Reports a summary of added and updated records. + +When a provider supports multiple media types (for example, `audio` *and* `images`), the `pull` step consumes data of all types, but separate `load` steps are generated: + +![Example Multi-Media DAG](assets/provider_dags/multi_media_dag.png) + +## Adding a New Provider + +Adding a new provider to Openverse means adding a new provider DAG. Fortunately, our DAG factories automate most of this process. To generate a fully functioning provider DAG, you need to: + +1. Implement a `ProviderDataIngester` +2. Add a `ProviderWorkflow` configuration class + +### Implementing a `ProviderDataIngester` class + +We call the code that pulls data from our provider APIs "Provider API scripts". You can find examples in [`provider_api_scripts` folder](../dags/providers/provider_api_scripts). This code will be run during the `pull` steps of the provider DAG. + +At a high level, a provider script should iteratively request batches of records from the provider API, extract data in the format required by Openverse, and commit it to local storage. Much of this logic is implemented in a [`ProviderDataIngester` base class](../dags/providers/provider_api_scripts/provider_data_ingester.py) (which also provides additional testing features ). All you need to do to add a new provider is extend this class and implement its abstract methods. + +We provide a [script](../dags/templates/create_provider_ingester.py) that can be used to generate the files you'll need and get you started: + +``` +# PROVIDER: The name of the provider +# ENDPOINT: The API endpoint from which to fetch data +# MEDIA: Optionally, a space-delineated list of media types ingested by this provider (and supported by Openverse). If not provided, defaults to "image" + +> create_provider_data_ingester.py -m + +# Example usage: + +> python3 openverse_catalog/dags/templates/create_provider_ingester.py "Foobar Museum" "https://foobar.museum.org/api/v1" -m "image audio" +``` + +You should see output similar to this: +``` +Creating files in /Users/staci/projects/openverse-projects/openverse-catalog +API script: openverse-catalog/openverse_catalog/dags/providers/provider_api_scripts/foobar_museum.py +API script test: openverse-catalog/tests/dags/providers/provider_api_scripts/test_foobar_museum.py + +NOTE: You will also need to add a new ProviderWorkflow dataclass configuration to the PROVIDER_WORKFLOWS list in `openverse-catalog/dags/providers/provider_workflows.py`. +``` + +This generates a provider script with a templated `ProviderDataIngester` for you in the [`provider_api_scripts` folder](../dags/providers/provider_api_scripts), as well as a corresponding test file. Complete the TODOs detailed in the generated files to implement behavior specific to your API. + +Some APIs may not fit perfectly into the established `ProviderDataIngester` pattern. For advanced use cases and examples of how to modify the ingestion flow, see the [`ProviderDataIngester` FAQ](provider_data_ingester_faq.md). + + +### Add a `ProviderWorkflow` configuration class + +Now that you have an ingester class, you're ready to wire up a provider DAG in Airflow to automatically pull data and load it into our Catalog database. This is as simple as defining a `ProviderWorkflow` configuration dataclass and adding it to the `PROVIDER_WORKFLOWS` list in [`provider_workflows.py`](../dags/providers/provider_workflows.py). Our DAG factories will pick up the configuration and generate a complete new DAG in Airflow! + +At minimum, you'll need to provide the following in your configuration: +* `provider_script`: the name of the file where you defined your `ProviderDataIngester` class +* `ingestion_callable`: the `ProviderDataIngester` class itself +* `media_types`: the media types your provider handles + +Example: +``` +# In openverse_catalog/dags/providers/provider_workflows.py +# Import your new ingester class +from providers.provider_api_scripts.foobar_museum import FoobarMuseumDataIngester + +... + +PROVIDER_WORKFLOWS = [ + ... + ProviderWorkflow( + provider_script='foobar_museum', + ingestion_callable=FooBarMuseumDataIngester, + media_types=("image", "audio",) + ) +] +``` + +There are many other options that allow you to tweak the `schedule` (when and how often your DAG is run), timeouts for individual steps of the DAG, and more. These are documented in the definition of the `ProviderWorkflow` dataclass. + +After adding your configuration, run `just up` and you should now have a fully functioning provider DAG! *NOTE*: when your code is merged, the DAG will become available in production but will be disabled by default. A contributor with Airflow access will need to manually turn the DAG on in production. diff --git a/openverse_catalog/docs/assets/provider_dags/multi_media_dag.png b/openverse_catalog/docs/assets/provider_dags/multi_media_dag.png new file mode 100644 index 0000000000000000000000000000000000000000..ac330030f076fb1afa98e69f3c7a1ce095e86f5c GIT binary patch literal 75026 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 X&2Oz6{d?*1#o!#2vpxZT+iRW<1) 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%0D$8BP$imlgfnJuzEFhJsiLh#mj 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 H^Y;G$vlC6R literal 0 HcmV?d00001 diff --git a/openverse_catalog/docs/assets/provider_dags/simple_dag.png b/openverse_catalog/docs/assets/provider_dags/simple_dag.png new file mode 100644 index 0000000000000000000000000000000000000000..a49958313a1af7c1c61580c0ed3b466d2ac32bfe GIT binary patch literal 49722 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 RIStGhqOPK&T%>3j{9hD=h(Z7W literal 0 HcmV?d00001 diff --git a/openverse_catalog/docs/data_models.md b/openverse_catalog/docs/data_models.md new file mode 100644 index 000000000..c1b9d12b8 --- /dev/null +++ b/openverse_catalog/docs/data_models.md @@ -0,0 +1,54 @@ +TODO: This documentation is temporary and should be replaced by more thorough documentation of our DB fields in https://github.com/WordPress/openverse-catalog/issues/783> + +# Data Models + +The following is temporary, limited documentation of the columns for each of our Catalog data models. + +*Required Fields* +| field name | description | +| --- | --- | +| *foreign_landing_url* | URL of page where the record lives on the source website. | +| *audio_url* / *image_url* | Direct link to the media file. Note that the field name differs depending on media type. | +| *license_info* | LicenseInfo object that has (1) the URL of the license for the record, (2) string representation of the license, (3) version of the license, (4) raw license URL that was by provider, if different from canonical URL | + +The following fields are optional, but it is highly encouraged to populate as much data as possible: + +| field name | description | +| --- | --- | +| *foreign_identifier* | Unique identifier for the record on the source site. | +| *thumbnail_url* | Direct link to a thumbnail-sized version of the record. | +| *filesize* | Size of the main file in bytes. | +| *filetype* | The filetype of the main file, eg. 'mp3', 'jpg', etc. | +| *creator* | The creator of the image. | +| *creator_url* | The user page, or home page of the creator. | +| *title* | Title of the record. | +| *meta_data* | Dictionary of metadata about the record. Currently, a key we prefer to have is `description`. | +| *raw_tags* | List of tags associated with the record. | +| *watermarked* | Boolean, true if the record has a watermark. | + +#### Image-specific fields + +Image also has the following fields: + +| field_name | description | +| --- | --- | +| *width* | Image width in pixels. | +| *height* | Image height in pixels. | + +#### Audio-specific fields + +Audio has the following fields: + +| field_name | description | +| --- | --- | +| *duration* | Audio duration in milliseconds. | +| *bit_rate* | Audio bit rate as int. | +| *sample_rate* | Audio sample rate as int. | +| *category* | Category such as 'music', 'sound', 'audio_book', or 'podcast'. | +| *genres* | List of genres. | +| *set_foreign_id* | Unique identifier for the audio set on the source site. | +| *audio_set* | The name of the set (album, pack, etc) the audio is part of. | +| *set_position* | Position of the audio in the audio_set. | +| *set_thumbnail* | URL of the audio_set thumbnail. | +| *set_url* | URL of the audio_set. | +| *alt_files* | A dictionary with information about alternative files for the audio (different formats/quality). Dict should have the following keys: url, filesize, bit_rate, sample_rate. diff --git a/openverse_catalog/docs/provider_data_ingester_faq.md b/openverse_catalog/docs/provider_data_ingester_faq.md new file mode 100644 index 000000000..a540539b7 --- /dev/null +++ b/openverse_catalog/docs/provider_data_ingester_faq.md @@ -0,0 +1,137 @@ +# ProviderDataIngester FAQ + +The most straightforward implementation of a `ProviderDataIngester` repeats the following process: + +* Builds a set of query params for the next request, based upon the previous params (for example, by updating offsets or page numbers) +* Makes a single GET request to the configured static `endpoint` using the built params +* Extracts a "batch" of records from the response, as a list of record JSON representations +* Iterates over the records in the batch, extracting desired data, and commits them to local storage + +Some provider APIs may not fit neatly into this workflow. This document addresses some common use cases. + +## How do I process a "record" that contains data about multiple records? + +**Example**: you're pulling data from a Museum database, and each "record" in a batch contains multiple photos of a single physical object. + +**Solution**: The `get_record_data` method takes a `data` object representing a single record from the provider API. Typically, it extracts required data and returns it as a single dict. However, it can also return a **list of dictionaries** for cases like the one described, where multiple Openverse records can be extracted. + +``` +def get_record_data(self, data: dict) -> dict | list[dict] | None: + records = [] + + for record in data.get("associatedImages", []): + # Perform data processing for each record and add it to a list + records.append({ + "foreign_landing_url": record.get("foreign_landing_url") + ... + }) + + return records +``` + +## What if I can't get all the necessary information for a record from a single API request? + +**Example**: A provider's `search` endpoint returns a list of image records containing *most* of the information we need for each record, but not image dimensions. This information *is* available via the API by hitting a `details` endpoint for a given image, though. + +**Solution**: In this case, you can reuse the `get_response_json` method by passing in the endpoint you need: + +``` +def get_record_data(self, data: dict) -> dict | list[dict] | None: + ... + + # Get data from the details endpoint + response_json = self.get_response_json( + query_params={"my_params": "foo"}, + endpoint=f"https://foobar.museum.org/api/v1/images/{data.get("uuid")}" + ) + +``` + +When doing this, keep in mind that adding too many requests may slow down ingestion. Be aware of rate limits from your provider API as well. + +## What if my API endpoint isn't static and needs to change from one request to another? + +Example: Rather than passing a `page` number in query parameters, a provider expects the `page` as part of the endpoint path itself. + +If your `endpoint` needs to change, you can implement it as a `property`: + +``` +@property +def endpoint(self) -> str: + # Compute the endpoint using some instance variable + return f"https://foobar.museum.org/images/page/{self.page_number}" +``` + +In this example, `self.page_number` is an instance variable that gets updated after each request. To set up the instance variable you can override `__init__`, **being careful to remember to call `super` and pass through kwargs**, and then update it in `get_next_query_params`: + +``` +def __init__(self, *args, **kwargs): + # IMPORTANT! + super().__init__(*args, **kwargs) + + # Set up our instance variable + self.page_number = None + +def get_next_query_params(self, prev_query_params: dict | None, **kwargs) -> dict: + # Set it to the initial value + if self.page_number is None: + self.page_number = 0 + else: + self.page_number += 1 + + # Return your actual query params + return {} +``` + +Now each time `get_batch` is called, the `endpoint` is correctly 'calculated'. + +## How do I run ingestion for a set of discrete categories? + +**Example**: My provider has some set of categories that I'd like to iterate over and ingest data for. Eg, an audio provider's search endpoint that requires you specify whether you're searching for "podcasts", "music", etc. I'd like to iterate over all the available categories and run ingestiotn for each. + +**Solution**: You can do this by overriding the `ingest_records` method, which accepts optional `kwargs` that it passes through on each call to `get_next_query_params`. This is best demonstrated with code: + +``` +CATEGORIES = ["music", "audio_book", "podcast"] + +def ingest_records(self, **kwargs): + # Iterate over categories and call the main ingestion function, passing in + # our category as a kwarg + for category in CATEGORIES: + super().ingest_records(category=category) + +def get_next_query_params(self, prev_query_params, **kwargs): + # Our category param will be available here + category = kwargs.get("category") + + # Add it to your query params + return { + "category": category, + ... + } +``` + +This will result in the ingestion function running once for each category. + +## What if I need to do more complex processing to get a batch? + +**Example**: A single GET request is insufficient to get a batch from a provider. Instead, several requests need to be made in sequence until a "batchcomplete" token is encountered. + +**Solution**: You can ovveride `get_response_json` in order to implement more complex behavior. + +``` +# Psuedo code serves as an example +def get_response_json( + self, query_params: dict, endpoint: str | None = None, **kwargs +): + batch_json = None + + while True: + partial_response = super().get_response_json(query_params) + batch_json = self.merge_data(batch_json, partial_response) + + if "batchcomplete" in response_json: + break + + return batch_json +``` From 8f1ad6dc82ec0b383cffa5ff9fe0406b00e61cee Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Wed, 12 Oct 2022 17:56:37 -0700 Subject: [PATCH 09/18] Small tweaks --- openverse_catalog/docs/adding_a_new_provider.md | 14 ++++++++------ openverse_catalog/docs/data_models.md | 5 ++++- .../docs/provider_data_ingester_faq.md | 17 +++++++++++------ 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/openverse_catalog/docs/adding_a_new_provider.md b/openverse_catalog/docs/adding_a_new_provider.md index e88250430..3ddc7b1d7 100644 --- a/openverse_catalog/docs/adding_a_new_provider.md +++ b/openverse_catalog/docs/adding_a_new_provider.md @@ -1,4 +1,6 @@ -# Openverse Provider DAGs +# Openverse Providers + +## Overview The Openverse Catalog collects data from the APIs of sites that share openly-licensed media,and saves them in our Catalog database. This process is automated by Airflow DAGs generated for each provider. A simple provider DAG looks like this: @@ -26,14 +28,15 @@ Adding a new provider to Openverse means adding a new provider DAG. Fortunately, We call the code that pulls data from our provider APIs "Provider API scripts". You can find examples in [`provider_api_scripts` folder](../dags/providers/provider_api_scripts). This code will be run during the `pull` steps of the provider DAG. -At a high level, a provider script should iteratively request batches of records from the provider API, extract data in the format required by Openverse, and commit it to local storage. Much of this logic is implemented in a [`ProviderDataIngester` base class](../dags/providers/provider_api_scripts/provider_data_ingester.py) (which also provides additional testing features ). All you need to do to add a new provider is extend this class and implement its abstract methods. +At a high level, a provider script should iteratively request batches of records from the provider API, extract data in the format required by Openverse, and commit it to local storage. Much of this logic is implemented in a [`ProviderDataIngester` base class](../dags/providers/provider_api_scripts/provider_data_ingester.py) (which also provides additional testing features **). All you need to do to add a new provider is extend this class and implement its abstract methods. We provide a [script](../dags/templates/create_provider_ingester.py) that can be used to generate the files you'll need and get you started: ``` # PROVIDER: The name of the provider # ENDPOINT: The API endpoint from which to fetch data -# MEDIA: Optionally, a space-delineated list of media types ingested by this provider (and supported by Openverse). If not provided, defaults to "image" +# MEDIA: Optionally, a space-delineated list of media types ingested by this provider +# (and supported by Openverse). If not provided, defaults to "image". > create_provider_data_ingester.py -m @@ -68,7 +71,6 @@ At minimum, you'll need to provide the following in your configuration: Example: ``` # In openverse_catalog/dags/providers/provider_workflows.py -# Import your new ingester class from providers.provider_api_scripts.foobar_museum import FoobarMuseumDataIngester ... @@ -83,6 +85,6 @@ PROVIDER_WORKFLOWS = [ ] ``` -There are many other options that allow you to tweak the `schedule` (when and how often your DAG is run), timeouts for individual steps of the DAG, and more. These are documented in the definition of the `ProviderWorkflow` dataclass. +There are many other options that allow you to tweak the `schedule` (when and how often your DAG is run), timeouts for individual steps of the DAG, and more. These are documented in the definition of the `ProviderWorkflow` dataclass. ** -After adding your configuration, run `just up` and you should now have a fully functioning provider DAG! *NOTE*: when your code is merged, the DAG will become available in production but will be disabled by default. A contributor with Airflow access will need to manually turn the DAG on in production. +After adding your configuration, run `just up` and you should now have a fully functioning provider DAG! ** *NOTE*: when your code is merged, the DAG will become available in production but will be disabled by default. A contributor with Airflow access will need to manually turn the DAG on in production. diff --git a/openverse_catalog/docs/data_models.md b/openverse_catalog/docs/data_models.md index c1b9d12b8..fa12394c2 100644 --- a/openverse_catalog/docs/data_models.md +++ b/openverse_catalog/docs/data_models.md @@ -4,13 +4,16 @@ TODO: This documentation is temporary and should be replaced by more thorough do The following is temporary, limited documentation of the columns for each of our Catalog data models. -*Required Fields* +## Required Fields + | field name | description | | --- | --- | | *foreign_landing_url* | URL of page where the record lives on the source website. | | *audio_url* / *image_url* | Direct link to the media file. Note that the field name differs depending on media type. | | *license_info* | LicenseInfo object that has (1) the URL of the license for the record, (2) string representation of the license, (3) version of the license, (4) raw license URL that was by provider, if different from canonical URL | +## Optional Fields + The following fields are optional, but it is highly encouraged to populate as much data as possible: | field name | description | diff --git a/openverse_catalog/docs/provider_data_ingester_faq.md b/openverse_catalog/docs/provider_data_ingester_faq.md index a540539b7..f345af809 100644 --- a/openverse_catalog/docs/provider_data_ingester_faq.md +++ b/openverse_catalog/docs/provider_data_ingester_faq.md @@ -9,9 +9,9 @@ The most straightforward implementation of a `ProviderDataIngester` repeats the Some provider APIs may not fit neatly into this workflow. This document addresses some common use cases. -## How do I process a "record" that contains data about multiple records? +## How do I process a provider "record" that contains data about multiple records? -**Example**: you're pulling data from a Museum database, and each "record" in a batch contains multiple photos of a single physical object. +**Example**: You're pulling data from a Museum database, and each "record" in a batch contains multiple photos of a single physical object. **Solution**: The `get_record_data` method takes a `data` object representing a single record from the provider API. Typically, it extracts required data and returns it as a single dict. However, it can also return a **list of dictionaries** for cases like the one described, where multiple Openverse records can be extracted. @@ -45,6 +45,7 @@ def get_record_data(self, data: dict) -> dict | list[dict] | None: endpoint=f"https://foobar.museum.org/api/v1/images/{data.get("uuid")}" ) + ... ``` When doing this, keep in mind that adding too many requests may slow down ingestion. Be aware of rate limits from your provider API as well. @@ -73,21 +74,25 @@ def __init__(self, *args, **kwargs): self.page_number = None def get_next_query_params(self, prev_query_params: dict | None, **kwargs) -> dict: - # Set it to the initial value + # Remember that `get_next_query_params` is called before every request, even + # the first one. + if self.page_number is None: + # Set initial value self.page_number = 0 else: + # Increment value on subsequent requests self.page_number += 1 # Return your actual query params return {} ``` -Now each time `get_batch` is called, the `endpoint` is correctly 'calculated'. +Now each time `get_batch` is called, the `endpoint` is correctly updated. ## How do I run ingestion for a set of discrete categories? -**Example**: My provider has some set of categories that I'd like to iterate over and ingest data for. Eg, an audio provider's search endpoint that requires you specify whether you're searching for "podcasts", "music", etc. I'd like to iterate over all the available categories and run ingestiotn for each. +**Example**: My provider has some set of categories that I'd like to iterate over and ingest data for. Eg, an audio provider's search endpoint that requires you specify whether you're searching for "podcasts", "music", etc. I'd like to iterate over all the available categories and run ingestion for each. **Solution**: You can do this by overriding the `ingest_records` method, which accepts optional `kwargs` that it passes through on each call to `get_next_query_params`. This is best demonstrated with code: @@ -117,7 +122,7 @@ This will result in the ingestion function running once for each category. **Example**: A single GET request is insufficient to get a batch from a provider. Instead, several requests need to be made in sequence until a "batchcomplete" token is encountered. -**Solution**: You can ovveride `get_response_json` in order to implement more complex behavior. +**Solution**: You can override `get_response_json` in order to implement more complex behavior. ``` # Psuedo code serves as an example From ab3dcbeca64ffb96331a15de23c90416cab4df6c Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Mon, 17 Oct 2022 13:40:10 -0700 Subject: [PATCH 10/18] Address feedback, sanitize provider string --- openverse_catalog/templates/__init__.py | 0 .../templates/create_provider_ingester.py | 43 +++++++++++----- .../templates/template_provider.py_template | 5 +- .../templates/template_test.py_template | 2 +- .../test_create_provider_ingester.py | 38 -------------- .../test_create_provider_ingester.py | 50 +++++++++++++++++++ 6 files changed, 84 insertions(+), 54 deletions(-) create mode 100644 openverse_catalog/templates/__init__.py rename openverse_catalog/{dags => }/templates/create_provider_ingester.py (85%) rename openverse_catalog/{dags => }/templates/template_provider.py_template (97%) rename openverse_catalog/{dags => }/templates/template_test.py_template (96%) delete mode 100644 tests/dags/templates/test_create_provider_ingester.py create mode 100644 tests/templates/test_create_provider_ingester.py diff --git a/openverse_catalog/templates/__init__.py b/openverse_catalog/templates/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openverse_catalog/dags/templates/create_provider_ingester.py b/openverse_catalog/templates/create_provider_ingester.py similarity index 85% rename from openverse_catalog/dags/templates/create_provider_ingester.py rename to openverse_catalog/templates/create_provider_ingester.py index a5e6eec0a..a1e6c7c84 100644 --- a/openverse_catalog/dags/templates/create_provider_ingester.py +++ b/openverse_catalog/templates/create_provider_ingester.py @@ -3,13 +3,14 @@ """ import argparse +import re from pathlib import Path import inflection TEMPLATES_PATH = Path(__file__).parent -REPO_PATH = TEMPLATES_PATH.parents[2] +REPO_PATH = TEMPLATES_PATH.parent PROJECT_PATH = REPO_PATH.parent MEDIA_TYPES = ["audio", "image"] @@ -65,9 +66,8 @@ def _render_file( def fill_template(provider, endpoint, media_types): print(f"Creating files in {REPO_PATH}") - provider = provider.replace(" ", "_") - dags_path = TEMPLATES_PATH.parent / "providers" + dags_path = TEMPLATES_PATH.parent / "dags" / "providers" api_path = dags_path / "provider_api_scripts" filename = inflection.underscore(provider) @@ -85,9 +85,10 @@ def fill_template(provider, endpoint, media_types): # Render the tests script_template_path = TEMPLATES_PATH / "template_test.py_template" - tests_path = REPO_PATH / "tests" + tests_path = PROJECT_PATH / "tests" # Mirror the directory structure, but under the "tests" top level directory test_script_path = tests_path.joinpath(*api_path.parts[-3:]) / f"test_{filename}.py" + _render_file( test_script_path, script_template_path, @@ -105,6 +106,22 @@ def fill_template(provider, endpoint, media_types): ) +def sanitize_provider(provider: str) -> str: + """ + Takes a provider string from user input and sanitizes it by: + - removing trailing whitespace + - replacing spaces and periods with underscores + - removing all characters other than alphanumeric characters, dashes, + and underscores. + + Eg: sanitize_provider("hello world.foo*/bar2&") -> "hello_world_foobar2" + """ + provider = provider.strip().replace(" ", "_").replace(".", "_") + + # Remove unsupported characters + return re.sub("[^0-9a-xA-Z-_]+", "", provider) + + def main(): parser = argparse.ArgumentParser( description="Create a new provider API ProviderDataIngester", @@ -127,24 +144,24 @@ def main(): " ('audio'/'image'). Default value is ['image',]", ) args = parser.parse_args() - provider = args.provider + provider = sanitize_provider(args.provider) endpoint = args.endpoint - # Get valid media types media_types = [] - for media_type in args.media: - if media_type in MEDIA_TYPES: - media_types.append(media_type) - else: - print(f"Ignoring invalid type {media_type}") - # Default to image if no valid media types given - if not media_types: + if not args.media: print('No media type given, defaulting to ["image",]') media_types = [ "image", ] + # Get valid media types + for media_type in args.media: + if media_type in MEDIA_TYPES: + media_types.append(media_type) + else: + print(f"Ignoring invalid type {media_type}") + fill_template(provider, endpoint, media_types) diff --git a/openverse_catalog/dags/templates/template_provider.py_template b/openverse_catalog/templates/template_provider.py_template similarity index 97% rename from openverse_catalog/dags/templates/template_provider.py_template rename to openverse_catalog/templates/template_provider.py_template index 722a32108..9ca33491b 100644 --- a/openverse_catalog/dags/templates/template_provider.py_template +++ b/openverse_catalog/templates/template_provider.py_template @@ -1,7 +1,8 @@ import logging +from airflow.models import Variable from common import constants -from common.license import get_license_info +from common.licenses import get_license_info from common.loader import provider_details as prov from providers.provider_api_scripts.provider_data_ingester import ProviderDataIngester @@ -22,7 +23,7 @@ class {provider}DataIngester(ProviderDataIngester): } endpoint = "{endpoint}" # TODO The following are set to their default values. Remove them if the defaults - # are acceptible, or override them. + # are acceptable, or override them. delay = 1 retries = 3 batch_limit = 100 diff --git a/openverse_catalog/dags/templates/template_test.py_template b/openverse_catalog/templates/template_test.py_template similarity index 96% rename from openverse_catalog/dags/templates/template_test.py_template rename to openverse_catalog/templates/template_test.py_template index 12ad474b5..abe885c3f 100644 --- a/openverse_catalog/dags/templates/template_test.py_template +++ b/openverse_catalog/templates/template_test.py_template @@ -14,7 +14,7 @@ import pytest from providers.provider_api_scripts.{provider_underscore} import {provider_data_ingester} # TODO: API responses used for testing can be added to this directory -RESOURCES = Path(__file__).parent / 'tests/resources/{provider_underscore}' +RESOURCES = Path(__file__).parent / "tests/resources/{provider_underscore}" # Set up test class ingester = {provider_data_ingester}() diff --git a/tests/dags/templates/test_create_provider_ingester.py b/tests/dags/templates/test_create_provider_ingester.py deleted file mode 100644 index 095f29fbe..000000000 --- a/tests/dags/templates/test_create_provider_ingester.py +++ /dev/null @@ -1,38 +0,0 @@ -from pathlib import Path - -import pytest - -from openverse_catalog.dags.templates import create_provider_ingester - - -@pytest.mark.parametrize( - "provider", - [ - ("Foobar Industries"), - ("FOOBAR INDUSTRIES"), - ("Foobar_industries"), - ("FoobarIndustries"), - ("foobar industries"), - ("foobar_industries"), - ], -) -def test_files_created(provider): - endpoint = "https://myfakeapi/v1" - media_type = "image" - dags_path = create_provider_ingester.TEMPLATES_PATH.parent / "providers" - expected_provider = dags_path / "provider_api_scripts" / "foobar_industries.py" - expected_test = ( - Path(__file__).parents[2] - / "dags" - / "providers" - / "provider_api_scripts" - / "test_foobar_industries.py" - ) - try: - create_provider_ingester.fill_template(provider, endpoint, media_type) - assert expected_provider.exists() - assert expected_test.exists() - finally: - # Clean up - expected_provider.unlink(missing_ok=True) - expected_test.unlink(missing_ok=True) diff --git a/tests/templates/test_create_provider_ingester.py b/tests/templates/test_create_provider_ingester.py new file mode 100644 index 000000000..847a353b2 --- /dev/null +++ b/tests/templates/test_create_provider_ingester.py @@ -0,0 +1,50 @@ +from pathlib import Path + +import pytest + +from openverse_catalog.templates import create_provider_ingester # , sanitize_provider + + +@pytest.mark.parametrize( + "provider, expected_result", + [ + ("FoobarIndustries", "FoobarIndustries"), + # Do not remove hyphens or underscores + ("hello-world_foo", "hello-world_foo"), + # Replace spaces + ("Foobar Industries", "Foobar_Industries"), + # Replace periods + ("foobar.com", "foobar_com"), + # Remove trailing whitespace + (" hello world ", "hello_world"), + # Replace special characters + ("hello.world-foo*/bar2", "hello_world-foobar2"), + ], +) +def test_sanitize_provider(provider, expected_result): + actual_result = create_provider_ingester.sanitize_provider(provider) + assert actual_result == expected_result + + +def test_files_created(): + provider = "foobar_industries" + endpoint = "https://myfakeapi/v1" + media_type = "image" + + dags_path = create_provider_ingester.TEMPLATES_PATH.parent / "dags" / "providers" + expected_provider = dags_path / "provider_api_scripts" / "foobar_industries.py" + expected_test = ( + Path(__file__).parents[1] + / "dags" + / "providers" + / "provider_api_scripts" + / "test_foobar_industries.py" + ) + try: + create_provider_ingester.fill_template(provider, endpoint, media_type) + assert expected_provider.exists() + assert expected_test.exists() + finally: + # Clean up + expected_provider.unlink(missing_ok=True) + expected_test.unlink(missing_ok=True) From 1f28f0608aaa63923b6b641b657d5e4f2faadeb5 Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Mon, 17 Oct 2022 14:13:58 -0700 Subject: [PATCH 11/18] Fix defaults for media types, add tests --- .../docs/adding_a_new_provider.md | 2 +- .../templates/create_provider_ingester.py | 41 +++++++----- .../test_create_provider_ingester.py | 62 ++++++++++++++++++- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/openverse_catalog/docs/adding_a_new_provider.md b/openverse_catalog/docs/adding_a_new_provider.md index 3ddc7b1d7..c490779ec 100644 --- a/openverse_catalog/docs/adding_a_new_provider.md +++ b/openverse_catalog/docs/adding_a_new_provider.md @@ -42,7 +42,7 @@ We provide a [script](../dags/templates/create_provider_ingester.py) that can be # Example usage: -> python3 openverse_catalog/dags/templates/create_provider_ingester.py "Foobar Museum" "https://foobar.museum.org/api/v1" -m "image audio" +> python3 openverse_catalog/templates/create_provider_ingester.py "Foobar Museum" "https://foobar.museum.org/api/v1" -m image audio ``` You should see output similar to this: diff --git a/openverse_catalog/templates/create_provider_ingester.py b/openverse_catalog/templates/create_provider_ingester.py index a1e6c7c84..cc5cbb20d 100644 --- a/openverse_catalog/templates/create_provider_ingester.py +++ b/openverse_catalog/templates/create_provider_ingester.py @@ -122,6 +122,31 @@ def sanitize_provider(provider: str) -> str: return re.sub("[^0-9a-xA-Z-_]+", "", provider) +def parse_media_types(media_types: list[str]) -> list[str]: + """ + Parses valid media types out from user input. Defaults to ["image",] + """ + valid_media_types = [] + + if media_types is None: + media_types = [] + + for media_type in media_types: + if media_type in MEDIA_TYPES: + valid_media_types.append(media_type) + else: + print(f"Ignoring invalid type {media_type}") + + # Default to image if no valid types given + if not valid_media_types: + print('No media type given, defaulting to ["image",]') + return [ + "image", + ] + + return valid_media_types + + def main(): parser = argparse.ArgumentParser( description="Create a new provider API ProviderDataIngester", @@ -146,21 +171,7 @@ def main(): args = parser.parse_args() provider = sanitize_provider(args.provider) endpoint = args.endpoint - - media_types = [] - # Default to image if no valid media types given - if not args.media: - print('No media type given, defaulting to ["image",]') - media_types = [ - "image", - ] - - # Get valid media types - for media_type in args.media: - if media_type in MEDIA_TYPES: - media_types.append(media_type) - else: - print(f"Ignoring invalid type {media_type}") + media_types = parse_media_types(args.media) fill_template(provider, endpoint, media_types) diff --git a/tests/templates/test_create_provider_ingester.py b/tests/templates/test_create_provider_ingester.py index 847a353b2..ab2b72093 100644 --- a/tests/templates/test_create_provider_ingester.py +++ b/tests/templates/test_create_provider_ingester.py @@ -2,7 +2,67 @@ import pytest -from openverse_catalog.templates import create_provider_ingester # , sanitize_provider +from openverse_catalog.templates import create_provider_ingester + + +@pytest.mark.parametrize( + "media_types_str, expected_types", + [ + # Just image + ( + [ + "image", + ], + [ + "image", + ], + ), + # Just audio + ( + [ + "audio", + ], + [ + "audio", + ], + ), + # Multiple valid types + (["image", "audio"], ["image", "audio"]), + # Discard only invalid types + ( + ["image", "blorfl"], + [ + "image", + ], + ), + (["blorfl", "audio", "image"], ["audio", "image"]), + # Defaults to image when all given types are invalid + ( + ["blorfl", "wat"], + [ + "image", + ], + ), + # Defaults to image when no types are given at all + ( + [ + "", + ], + [ + "image", + ], + ), + ( + None, + [ + "image", + ], + ), + ], +) +def test_parse_media_types(media_types_str, expected_types): + actual_result = create_provider_ingester.parse_media_types(media_types_str) + assert actual_result == expected_types @pytest.mark.parametrize( From 1f53df1687b112d56a563b097653e1995849f86a Mon Sep 17 00:00:00 2001 From: Staci Cooper <63313398+stacimc@users.noreply.github.com> Date: Mon, 17 Oct 2022 14:16:04 -0700 Subject: [PATCH 12/18] Adjust wording Co-authored-by: Zack Krida --- openverse_catalog/docs/adding_a_new_provider.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openverse_catalog/docs/adding_a_new_provider.md b/openverse_catalog/docs/adding_a_new_provider.md index c490779ec..760526fe4 100644 --- a/openverse_catalog/docs/adding_a_new_provider.md +++ b/openverse_catalog/docs/adding_a_new_provider.md @@ -28,7 +28,7 @@ Adding a new provider to Openverse means adding a new provider DAG. Fortunately, We call the code that pulls data from our provider APIs "Provider API scripts". You can find examples in [`provider_api_scripts` folder](../dags/providers/provider_api_scripts). This code will be run during the `pull` steps of the provider DAG. -At a high level, a provider script should iteratively request batches of records from the provider API, extract data in the format required by Openverse, and commit it to local storage. Much of this logic is implemented in a [`ProviderDataIngester` base class](../dags/providers/provider_api_scripts/provider_data_ingester.py) (which also provides additional testing features **). All you need to do to add a new provider is extend this class and implement its abstract methods. +At a high level, a provider script should iteratively request batches of records from the provider API, extract data in the format required by Openverse, and commit it to local storage. Much of this logic is implemented in a [`ProviderDataIngester` base class](../dags/providers/provider_api_scripts/provider_data_ingester.py) (which also provides additional testing features **). To add a new provider, extend this class and implement its abstract methods. We provide a [script](../dags/templates/create_provider_ingester.py) that can be used to generate the files you'll need and get you started: From a19460b4f33c5d8d2e47de5280bbc71734209ba2 Mon Sep 17 00:00:00 2001 From: Staci Cooper <63313398+stacimc@users.noreply.github.com> Date: Mon, 17 Oct 2022 14:29:18 -0700 Subject: [PATCH 13/18] Add syntax highlighting Co-authored-by: Krystle Salazar --- openverse_catalog/docs/provider_data_ingester_faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openverse_catalog/docs/provider_data_ingester_faq.md b/openverse_catalog/docs/provider_data_ingester_faq.md index f345af809..a00d0e752 100644 --- a/openverse_catalog/docs/provider_data_ingester_faq.md +++ b/openverse_catalog/docs/provider_data_ingester_faq.md @@ -15,7 +15,7 @@ Some provider APIs may not fit neatly into this workflow. This document addresse **Solution**: The `get_record_data` method takes a `data` object representing a single record from the provider API. Typically, it extracts required data and returns it as a single dict. However, it can also return a **list of dictionaries** for cases like the one described, where multiple Openverse records can be extracted. -``` +```python def get_record_data(self, data: dict) -> dict | list[dict] | None: records = [] From 006021534c3359fb8cbde619e976dcbd518997d7 Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Mon, 17 Oct 2022 14:30:37 -0700 Subject: [PATCH 14/18] Add syntax highlighting to the rest of the code snippets --- openverse_catalog/docs/adding_a_new_provider.md | 2 +- openverse_catalog/docs/provider_data_ingester_faq.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openverse_catalog/docs/adding_a_new_provider.md b/openverse_catalog/docs/adding_a_new_provider.md index 760526fe4..f8625aa04 100644 --- a/openverse_catalog/docs/adding_a_new_provider.md +++ b/openverse_catalog/docs/adding_a_new_provider.md @@ -69,7 +69,7 @@ At minimum, you'll need to provide the following in your configuration: * `media_types`: the media types your provider handles Example: -``` +```python # In openverse_catalog/dags/providers/provider_workflows.py from providers.provider_api_scripts.foobar_museum import FoobarMuseumDataIngester diff --git a/openverse_catalog/docs/provider_data_ingester_faq.md b/openverse_catalog/docs/provider_data_ingester_faq.md index a00d0e752..cb50aafab 100644 --- a/openverse_catalog/docs/provider_data_ingester_faq.md +++ b/openverse_catalog/docs/provider_data_ingester_faq.md @@ -35,7 +35,7 @@ def get_record_data(self, data: dict) -> dict | list[dict] | None: **Solution**: In this case, you can reuse the `get_response_json` method by passing in the endpoint you need: -``` +```python def get_record_data(self, data: dict) -> dict | list[dict] | None: ... @@ -56,7 +56,7 @@ Example: Rather than passing a `page` number in query parameters, a provider exp If your `endpoint` needs to change, you can implement it as a `property`: -``` +```python @property def endpoint(self) -> str: # Compute the endpoint using some instance variable @@ -65,7 +65,7 @@ def endpoint(self) -> str: In this example, `self.page_number` is an instance variable that gets updated after each request. To set up the instance variable you can override `__init__`, **being careful to remember to call `super` and pass through kwargs**, and then update it in `get_next_query_params`: -``` +```python def __init__(self, *args, **kwargs): # IMPORTANT! super().__init__(*args, **kwargs) @@ -96,7 +96,7 @@ Now each time `get_batch` is called, the `endpoint` is correctly updated. **Solution**: You can do this by overriding the `ingest_records` method, which accepts optional `kwargs` that it passes through on each call to `get_next_query_params`. This is best demonstrated with code: -``` +```python CATEGORIES = ["music", "audio_book", "podcast"] def ingest_records(self, **kwargs): @@ -124,7 +124,7 @@ This will result in the ingestion function running once for each category. **Solution**: You can override `get_response_json` in order to implement more complex behavior. -``` +```python # Psuedo code serves as an example def get_response_json( self, query_params: dict, endpoint: str | None = None, **kwargs From bdf131553aa187b4669059e055d170b20469ef4f Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Mon, 17 Oct 2022 14:33:09 -0700 Subject: [PATCH 15/18] Add DAG doc to the template --- .../templates/template_provider.py_template | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openverse_catalog/templates/template_provider.py_template b/openverse_catalog/templates/template_provider.py_template index 9ca33491b..53fc1c2d5 100644 --- a/openverse_catalog/templates/template_provider.py_template +++ b/openverse_catalog/templates/template_provider.py_template @@ -1,3 +1,17 @@ +""" +TODO: This doc string will be used to generate documentation for the DAG in +DAGs.md. Update it to include any relevant information that you'd like to +be documented. + +Content Provider: {provider} + +ETL Process: Use the API to identify all CC licensed images. + +Output: TSV file containing the images and the + respective meta-data. + +Notes: {endpoint} +""" import logging from airflow.models import Variable From 8a04b40d19a08d29991eb847851fc9f34a63eb8a Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Fri, 21 Oct 2022 09:15:23 -0700 Subject: [PATCH 16/18] Add just recipe --- justfile | 4 ++++ openverse_catalog/docs/adding_a_new_provider.md | 15 +++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/justfile b/justfile index dc26436aa..05457a79c 100644 --- a/justfile +++ b/justfile @@ -131,3 +131,7 @@ generate-dag-docs fail_on_diff="false": exit 1 fi fi + +# Generate files for a new provider +add-provider provider_name endpoint +media_types="image": + python3 openverse_catalog/templates/create_provider_ingester.py "{{ provider_name }}" "{{ endpoint }}" -m {{ media_types }} diff --git a/openverse_catalog/docs/adding_a_new_provider.md b/openverse_catalog/docs/adding_a_new_provider.md index f8625aa04..8586fcbc9 100644 --- a/openverse_catalog/docs/adding_a_new_provider.md +++ b/openverse_catalog/docs/adding_a_new_provider.md @@ -33,16 +33,23 @@ At a high level, a provider script should iteratively request batches of records We provide a [script](../dags/templates/create_provider_ingester.py) that can be used to generate the files you'll need and get you started: ``` -# PROVIDER: The name of the provider +# PROVIDER_NAME: The name of the provider # ENDPOINT: The API endpoint from which to fetch data # MEDIA: Optionally, a space-delineated list of media types ingested by this provider # (and supported by Openverse). If not provided, defaults to "image". -> create_provider_data_ingester.py -m +> just add-provider -# Example usage: +# Example usages: -> python3 openverse_catalog/templates/create_provider_ingester.py "Foobar Museum" "https://foobar.museum.org/api/v1" -m image audio +# Creates a provider that supports just audio +> just add-provider TestProvider https://test.test/search audio + +# Creates a provider that supports images and audio +> just add-provider "Foobar Museum" https://foobar.museum.org/api/v1 image audio + +# Creates a provider that supports the default, just image +> just add-provider TestProvider https://test.test/search ``` You should see output similar to this: From 75c87b14c668e7c7cd93eb9c395e4981255a8493 Mon Sep 17 00:00:00 2001 From: Staci Cooper Date: Fri, 21 Oct 2022 09:55:57 -0700 Subject: [PATCH 17/18] Address feedback --- .../docs/adding_a_new_provider.md | 14 +++--- openverse_catalog/docs/data_models.md | 10 ++-- .../docs/provider_data_ingester_faq.md | 10 ++-- .../templates/create_provider_ingester.py | 10 ++-- .../templates/template_provider.py_template | 11 +++-- .../templates/template_test.py_template | 3 +- .../test_create_provider_ingester.py | 48 +++---------------- 7 files changed, 36 insertions(+), 70 deletions(-) diff --git a/openverse_catalog/docs/adding_a_new_provider.md b/openverse_catalog/docs/adding_a_new_provider.md index 8586fcbc9..59d7f5f86 100644 --- a/openverse_catalog/docs/adding_a_new_provider.md +++ b/openverse_catalog/docs/adding_a_new_provider.md @@ -2,15 +2,15 @@ ## Overview -The Openverse Catalog collects data from the APIs of sites that share openly-licensed media,and saves them in our Catalog database. This process is automated by Airflow DAGs generated for each provider. A simple provider DAG looks like this: +The Openverse Catalog collects data from the APIs of sites that share openly-licensed media, and saves them in our Catalog database. This process is automated by [Airflow DAGs](https://airflow.apache.org/docs/apache-airflow/stable/concepts/dags.html) generated for each provider. A simple provider DAG looks like this: ![Example DAG](assets/provider_dags/simple_dag.png) At a high level the steps are: -1. `generate_filename`: Generates a TSV filename used in later steps -2. `pull_data`: Actually pulls records from the provider API, collects just the data we need, and commits it to local storage in TSVs. -3. `load_data`: Loads the data from TSVs into the actual Catalog database, updating old records and discarding duplicates. +1. `generate_filename`: Generates the named of a TSV (tab-separated values) text file that will be used for saving the data to the disk in later steps +2. `pull_data`: Pulls records from the provider API, collects just the data we need, and commits it to local storage in TSVs. +3. `load_data`: Loads the data from TSVs into the Catalog database, updating old records and discarding duplicates. 4. `report_load_completion`: Reports a summary of added and updated records. When a provider supports multiple media types (for example, `audio` *and* `images`), the `pull` step consumes data of all types, but separate `load` steps are generated: @@ -68,7 +68,7 @@ Some APIs may not fit perfectly into the established `ProviderDataIngester` patt ### Add a `ProviderWorkflow` configuration class -Now that you have an ingester class, you're ready to wire up a provider DAG in Airflow to automatically pull data and load it into our Catalog database. This is as simple as defining a `ProviderWorkflow` configuration dataclass and adding it to the `PROVIDER_WORKFLOWS` list in [`provider_workflows.py`](../dags/providers/provider_workflows.py). Our DAG factories will pick up the configuration and generate a complete new DAG in Airflow! +Now that you have an ingester class, you're ready to wire up a provider DAG in Airflow to automatically pull data and load it into our Catalog database. This is done by defining a `ProviderWorkflow` configuration dataclass and adding it to the `PROVIDER_WORKFLOWS` list in [`provider_workflows.py`](../dags/providers/provider_workflows.py). Our DAG factories will pick up the configuration and generate a complete new DAG in Airflow! At minimum, you'll need to provide the following in your configuration: * `provider_script`: the name of the file where you defined your `ProviderDataIngester` class @@ -94,4 +94,6 @@ PROVIDER_WORKFLOWS = [ There are many other options that allow you to tweak the `schedule` (when and how often your DAG is run), timeouts for individual steps of the DAG, and more. These are documented in the definition of the `ProviderWorkflow` dataclass. ** -After adding your configuration, run `just up` and you should now have a fully functioning provider DAG! ** *NOTE*: when your code is merged, the DAG will become available in production but will be disabled by default. A contributor with Airflow access will need to manually turn the DAG on in production. +After adding your configuration, run `just up` and you should now have a fully functioning provider DAG! ** + +*NOTE*: when your code is merged, the DAG will become available in production but will be disabled by default. A contributor with Airflow access will need to manually turn the DAG on in production. diff --git a/openverse_catalog/docs/data_models.md b/openverse_catalog/docs/data_models.md index fa12394c2..ec48aff13 100644 --- a/openverse_catalog/docs/data_models.md +++ b/openverse_catalog/docs/data_models.md @@ -1,4 +1,4 @@ -TODO: This documentation is temporary and should be replaced by more thorough documentation of our DB fields in https://github.com/WordPress/openverse-catalog/issues/783> +** # Data Models @@ -8,9 +8,10 @@ The following is temporary, limited documentation of the columns for each of our | field name | description | | --- | --- | +| *foreign_identifier* | Unique identifier for the record on the source site. | | *foreign_landing_url* | URL of page where the record lives on the source website. | -| *audio_url* / *image_url* | Direct link to the media file. Note that the field name differs depending on media type. | -| *license_info* | LicenseInfo object that has (1) the URL of the license for the record, (2) string representation of the license, (3) version of the license, (4) raw license URL that was by provider, if different from canonical URL | +| *audio_url* / *image_url* | Direct link to the media file. Note that until [issue #784 is addressed](https://github.com/WordPress/openverse-catalog/issues/784) the field name differs depending on media type. | +| *license_info* | [LicenseInfo object](https://github.com/WordPress/openverse-catalog/blob/8423590fd86a0a3272ca91bc11f2f37979048181/openverse_catalog/dags/common/licenses/licenses.py#L25) that has (1) the URL of the license for the record, (2) string representation of the license, (3) version of the license, (4) raw license URL that was by provider, if different from canonical URL. Usually generated by calling [`get_license_info`](https://github.com/WordPress/openverse-catalog/blob/8423590fd86a0a3272ca91bc11f2f37979048181/openverse_catalog/dags/common/licenses/licenses.py#L29) on respective fields returns/available from the API. | ## Optional Fields @@ -18,7 +19,6 @@ The following fields are optional, but it is highly encouraged to populate as mu | field name | description | | --- | --- | -| *foreign_identifier* | Unique identifier for the record on the source site. | | *thumbnail_url* | Direct link to a thumbnail-sized version of the record. | | *filesize* | Size of the main file in bytes. | | *filetype* | The filetype of the main file, eg. 'mp3', 'jpg', etc. | @@ -54,4 +54,4 @@ Audio has the following fields: | *set_position* | Position of the audio in the audio_set. | | *set_thumbnail* | URL of the audio_set thumbnail. | | *set_url* | URL of the audio_set. | -| *alt_files* | A dictionary with information about alternative files for the audio (different formats/quality). Dict should have the following keys: url, filesize, bit_rate, sample_rate. +| *alt_files* | A dictionary with information about alternative files for the audio (different formats/quality). Dict should have the following keys: *url*, *filesize*, *bit_rate*, *sample_rate*. diff --git a/openverse_catalog/docs/provider_data_ingester_faq.md b/openverse_catalog/docs/provider_data_ingester_faq.md index cb50aafab..612cf7bd8 100644 --- a/openverse_catalog/docs/provider_data_ingester_faq.md +++ b/openverse_catalog/docs/provider_data_ingester_faq.md @@ -48,13 +48,13 @@ def get_record_data(self, data: dict) -> dict | list[dict] | None: ... ``` -When doing this, keep in mind that adding too many requests may slow down ingestion. Be aware of rate limits from your provider API as well. +**NOTE**: When doing this, keep in mind that adding too many requests may slow down ingestion. Be aware of rate limits from your provider API as well. ## What if my API endpoint isn't static and needs to change from one request to another? -Example: Rather than passing a `page` number in query parameters, a provider expects the `page` as part of the endpoint path itself. +**Example**: Rather than passing a `page` number in query parameters, a provider expects the `page` as part of the endpoint path itself. -If your `endpoint` needs to change, you can implement it as a `property`: +**Solution**: If your `endpoint` needs to change, you can implement it as a `property`: ```python @property @@ -67,7 +67,7 @@ In this example, `self.page_number` is an instance variable that gets updated af ```python def __init__(self, *args, **kwargs): - # IMPORTANT! + # REQUIRED! super().__init__(*args, **kwargs) # Set up our instance variable @@ -92,7 +92,7 @@ Now each time `get_batch` is called, the `endpoint` is correctly updated. ## How do I run ingestion for a set of discrete categories? -**Example**: My provider has some set of categories that I'd like to iterate over and ingest data for. Eg, an audio provider's search endpoint that requires you specify whether you're searching for "podcasts", "music", etc. I'd like to iterate over all the available categories and run ingestion for each. +**Example**: My provider has some set of categories that I'd like to iterate over and ingest data for. E.g., a particular audio provider's search endpoint requires you specify whether you're searching for "podcasts", "music", etc. I'd like to iterate over all the available categories and run ingestion for each. **Solution**: You can do this by overriding the `ingest_records` method, which accepts optional `kwargs` that it passes through on each call to `get_next_query_params`. This is best demonstrated with code: diff --git a/openverse_catalog/templates/create_provider_ingester.py b/openverse_catalog/templates/create_provider_ingester.py index cc5cbb20d..74f8e61a1 100644 --- a/openverse_catalog/templates/create_provider_ingester.py +++ b/openverse_catalog/templates/create_provider_ingester.py @@ -10,8 +10,8 @@ TEMPLATES_PATH = Path(__file__).parent -REPO_PATH = TEMPLATES_PATH.parent -PROJECT_PATH = REPO_PATH.parent +PROJECT_PATH = TEMPLATES_PATH.parent +REPO_PATH = PROJECT_PATH.parent MEDIA_TYPES = ["audio", "image"] @@ -61,13 +61,13 @@ def _render_file( template_path, provider, endpoint, media_types ) target_file.write(filled_template) - print(f"{name + ':':<18} {target.relative_to(PROJECT_PATH)}") + print(f"{name + ':':<18} {target.relative_to(REPO_PATH)}") def fill_template(provider, endpoint, media_types): print(f"Creating files in {REPO_PATH}") - dags_path = TEMPLATES_PATH.parent / "dags" / "providers" + dags_path = PROJECT_PATH / "dags" / "providers" api_path = dags_path / "provider_api_scripts" filename = inflection.underscore(provider) @@ -85,7 +85,7 @@ def fill_template(provider, endpoint, media_types): # Render the tests script_template_path = TEMPLATES_PATH / "template_test.py_template" - tests_path = PROJECT_PATH / "tests" + tests_path = REPO_PATH / "tests" # Mirror the directory structure, but under the "tests" top level directory test_script_path = tests_path.joinpath(*api_path.parts[-3:]) / f"test_{filename}.py" diff --git a/openverse_catalog/templates/template_provider.py_template b/openverse_catalog/templates/template_provider.py_template index 53fc1c2d5..0e78c48af 100644 --- a/openverse_catalog/templates/template_provider.py_template +++ b/openverse_catalog/templates/template_provider.py_template @@ -5,9 +5,9 @@ be documented. Content Provider: {provider} -ETL Process: Use the API to identify all CC licensed images. +ETL Process: Use the API to identify all CC licensed media. -Output: TSV file containing the images and the +Output: TSV file containing the media and the respective meta-data. Notes: {endpoint} @@ -76,7 +76,7 @@ class {provider}DataIngester(ProviderDataIngester): def get_media_type(self, record: dict): # For a given record json, return the media type it represents. # TODO: Update based on your API. TIP: May be hard-coded if the provider only - # returns records of one type. + # returns records of one type, eg `return constants.IMAGE` return record['media_type'] def get_record_data(self, data: dict) -> dict | list[dict] | None: @@ -86,12 +86,15 @@ class {provider}DataIngester(ProviderDataIngester): # available fields in `openverse_catalog/docs/data_models.md` # REQUIRED FIELDS: + # - foreign_identifier # - foreign_landing_url # - license_info # - image_url / audio_url # # If a required field is missing, return early to prevent unnecesary # processing. + if (foreign_identifier = data.get("foreign_id")) is None: + return None if (foreign_landing_url := data.get("url")) is None: return None @@ -109,7 +112,6 @@ class {provider}DataIngester(ProviderDataIngester): # OPTIONAL FIELDS # Obtain as many optional fields as possible. - foreign_identifier = data.get("foreign_id") thumbnail_url = data.get("thumbnail") filesize = data.get("filesize") filetype = data.get("filetype") @@ -154,7 +156,6 @@ class {provider}DataIngester(ProviderDataIngester): def main(): # Allows running ingestion from the CLI without Airflow running for debugging # purposes. - logger.info("Begin: {provider} data ingestion") ingester = {provider}DataIngester() ingester.ingest_records() diff --git a/openverse_catalog/templates/template_test.py_template b/openverse_catalog/templates/template_test.py_template index abe885c3f..3c4073c40 100644 --- a/openverse_catalog/templates/template_test.py_template +++ b/openverse_catalog/templates/template_test.py_template @@ -7,14 +7,13 @@ Run your tests locally with `just test -k {provider_underscore}` """ import json -import logging from pathlib import Path import pytest from providers.provider_api_scripts.{provider_underscore} import {provider_data_ingester} # TODO: API responses used for testing can be added to this directory -RESOURCES = Path(__file__).parent / "tests/resources/{provider_underscore}" +RESOURCES = Path(__file__).parent / "resources/{provider_underscore}" # Set up test class ingester = {provider_data_ingester}() diff --git a/tests/templates/test_create_provider_ingester.py b/tests/templates/test_create_provider_ingester.py index ab2b72093..1d0c03635 100644 --- a/tests/templates/test_create_provider_ingester.py +++ b/tests/templates/test_create_provider_ingester.py @@ -9,55 +9,19 @@ "media_types_str, expected_types", [ # Just image - ( - [ - "image", - ], - [ - "image", - ], - ), + (["image"], ["image"]), # Just audio - ( - [ - "audio", - ], - [ - "audio", - ], - ), + (["audio"], ["audio"]), # Multiple valid types (["image", "audio"], ["image", "audio"]), # Discard only invalid types - ( - ["image", "blorfl"], - [ - "image", - ], - ), + (["image", "blorfl"], ["image"]), (["blorfl", "audio", "image"], ["audio", "image"]), # Defaults to image when all given types are invalid - ( - ["blorfl", "wat"], - [ - "image", - ], - ), + (["blorfl", "wat"], ["image"]), # Defaults to image when no types are given at all - ( - [ - "", - ], - [ - "image", - ], - ), - ( - None, - [ - "image", - ], - ), + ([""], ["image"]), + (None, ["image"]), ], ) def test_parse_media_types(media_types_str, expected_types): From d2d8a480aa1480228c446d176e184a70b23f83af Mon Sep 17 00:00:00 2001 From: Staci Cooper <63313398+stacimc@users.noreply.github.com> Date: Fri, 21 Oct 2022 11:49:33 -0700 Subject: [PATCH 18/18] Fix syntax Co-authored-by: Madison Swain-Bowden --- openverse_catalog/templates/template_provider.py_template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openverse_catalog/templates/template_provider.py_template b/openverse_catalog/templates/template_provider.py_template index 0e78c48af..1adab17ec 100644 --- a/openverse_catalog/templates/template_provider.py_template +++ b/openverse_catalog/templates/template_provider.py_template @@ -93,7 +93,7 @@ class {provider}DataIngester(ProviderDataIngester): # # If a required field is missing, return early to prevent unnecesary # processing. - if (foreign_identifier = data.get("foreign_id")) is None: + if (foreign_identifier := data.get("foreign_id")) is None: return None if (foreign_landing_url := data.get("url")) is None: