From 71419b3281015f0a06ece049656f6f664a6cbc45 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Mon, 28 Jul 2025 10:00:55 -0400 Subject: [PATCH 01/20] fix: better handle errors in celery result backend - use celery's own "retry on result-backend error" logic - new env var: `CELERY_RESULT_BACKEND_MAX_RETRIES` (default 17) - retry on OperationalError (for connection problems and such) and IntegrityError (for conflicting `get_or_create` in overlapping transactions, for example) - move `die_on_unhandled` decorator to public methods, so it's only used after max retries - port fixes from celery's `BaseKeyValueStoreBackend`: - avoid clobbering successes with "worker lost" or other errors - avoid error trying to get non-existent task results - use celery's `BaseBackend` instead of `BaseDictBackend` (equivalent for back-compat; let's use the better name) --- project/settings.py | 2 ++ share/celery.py | 75 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/project/settings.py b/project/settings.py index a29abf4ef..2f134cb84 100644 --- a/project/settings.py +++ b/project/settings.py @@ -336,6 +336,8 @@ def split(string, delim): } CELERY_RESULT_BACKEND = 'share.celery:CeleryDatabaseBackend' +CELERY_RESULT_BACKEND_ALWAYS_RETRY = True +CELERY_RESULT_BACKEND_MAX_RETRIES = int(os.environ.get('CELERY_RESULT_BACKEND_MAX_RETRIES', 17)) CELERY_RESULT_EXPIRES = int(os.environ.get( 'CELERY_RESULT_EXPIRES', 60 * 60 * 24 * 3, # 3 days diff --git a/share/celery.py b/share/celery.py index 663ddbba9..962e08c11 100644 --- a/share/celery.py +++ b/share/celery.py @@ -4,14 +4,16 @@ from celery import states from celery.app.task import Context -from celery.backends.base import BaseDictBackend +from celery.backends.base import BaseBackend from celery.utils.time import maybe_timedelta - from django.conf import settings -from django.db import transaction +from django.db import ( + transaction, + IntegrityError as DjIntegrityError, + OperationalError as DjOperationalError, +) from django.db.models import Q from django.utils import timezone - import sentry_sdk from share.models import CeleryTaskResult @@ -40,7 +42,7 @@ def wrapped(*args, **kwargs): # Based on https://github.com/celery/django-celery-results/commit/f88c677d66ba1eaf1b7cb1f3b8c910012990984f -class CeleryDatabaseBackend(BaseDictBackend): +class CeleryDatabaseBackend(BaseBackend): """ Implemented from scratch rather than subclassed due to: @@ -53,8 +55,53 @@ class CeleryDatabaseBackend(BaseDictBackend): """ TaskModel = CeleryTaskResult + ### + # decorate some methods to fully stop/restart the worker on unhandled errors, + # including safe-to-retry errors that have been maximally retried + # (restarting may resolve some problems; others it will merely make more visible) + + @die_on_unhandled + def get_task_meta(self, *args, **kwargs): + super().get_task_meta(*args, **kwargs) + + @die_on_unhandled + def store_result(self, *args, **kwargs): + super().store_result(*args, **kwargs) + @die_on_unhandled + def forget(self, *args, **kwargs): + super().forget(*args, **kwargs) + + @die_on_unhandled + def cleanup(self, expires=None): + # no super implementation + TaskResultCleaner( + success_ttl=(expires or self.expires), + nonsuccess_ttl=settings.FAILED_CELERY_RESULT_EXPIRES, + ).clean() + + # END die_on_unhandled decorations + ### + + # override BaseBackend + def exception_safe_to_retry(self, exc): + return isinstance(exc, ( + DjOperationalError, # connection errors and whatnot + DjIntegrityError, # e.g. overlapping transactions with conflicting `get_or_create` + )) + + # implement for BaseBackend def _store_result(self, task_id, result, status, traceback=None, request=None, **kwargs): + _already_successful = ( + self.TaskModel.objects + .filter(task_id=task_id, status=states.SUCCESS) + .exists() + ) + if _already_successful: + # avoid clobbering prior successful result, which could be caused by network partition or lost worker, ostensibly: + # https://github.com/celery/celery/blob/92514ac88afc4ccdff31f3a1018b04499607ca1e/celery/backends/base.py#L967-L972 + return + fields = { 'result': result, 'traceback': traceback, @@ -88,20 +135,14 @@ def _store_result(self, task_id, result, status, traceback=None, request=None, * setattr(obj, key, value) obj.save() - return obj - - @die_on_unhandled - def cleanup(self, expires=None): - TaskResultCleaner( - success_ttl=(expires or self.expires), - nonsuccess_ttl=settings.FAILED_CELERY_RESULT_EXPIRES, - ).clean() - - @die_on_unhandled + # implement for BaseBackend def _get_task_meta_for(self, task_id): - return self.TaskModel.objects.get(task_id=task_id).as_dict() + try: + return self.TaskModel.objects.get(task_id=task_id).as_dict() + except self.TaskModel.DoesNotExist: + return {'status': states.PENDING, 'result': None} - @die_on_unhandled + # implement for BaseBackend def _forget(self, task_id): try: self.TaskModel.objects.get(task_id=task_id).delete() From 69631d9f6358492f2ea5096b2af9d4b26b247d66 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Tue, 15 Jul 2025 17:54:04 -0400 Subject: [PATCH 02/20] better docs --- ARCHITECTURE.md | 169 +++++++++---------- CONTRIBUTING.md | 13 +- README.md | 34 +--- TODO.md | 86 ++++++++++ WHAT-IS-THIS-EVEN.md | 42 ----- how-to/add-a-source.rst | 251 ---------------------------- how-to/run-locally.md | 12 +- how-to/use-the-api.md | 21 ++- project/static/img/shtroverview.png | Bin 0 -> 151721 bytes 9 files changed, 206 insertions(+), 422 deletions(-) create mode 100644 TODO.md delete mode 100644 WHAT-IS-THIS-EVEN.md delete mode 100644 how-to/add-a-source.rst create mode 100644 project/static/img/shtroverview.png diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d2dd034d1..f27608ffc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,112 +1,107 @@ -# Architecture of SHARE/Trove -> NOTE: this document requires update (big ol' TODO) - +# Architecture of SHARE/trove This document is a starting point and reference to familiarize yourself with this codebase. ## Bird's eye view -In short, SHARE/Trove takes metadata records (in any supported input format), -ingests them, and makes them available in any supported output format. -``` - ┌───────────────────────────────────────────┐ - │ Ingest │ - │ ┌──────┐ │ - │ ┌─────────────────────────┐ ┌──►Format├─┼────┐ - │ │ Normalize │ │ └──────┘ │ │ - │ │ │ │ │ ▼ -┌───────┐ │ │ ┌─────────┐ ┌────────┐ │ │ ┌──────┐ │ save as -│Harvest├─┬─┼─┼─►Transform├──►Regulate├─┼─┬─┼──►Format├─┼─┬─►FormattedMetadataRecord -└───────┘ │ │ │ └─────────┘ └────────┘ │ │ │ └──────┘ │ │ - │ │ │ │ │ . │ │ ┌───────┐ - │ │ └─────────────────────────┘ │ . │ └──►Indexer│ - │ │ │ . │ └───────┘ - │ └─────────────────────────────┼─────────────┘ some formats also - │ │ indexed separately - ▼ ▼ - save as save as - RawDatum NormalizedData +In short, SHARE/trove holds metadata records that describe things and makes those records available for searching, browsing, and subscribing. + +![overview of shtrove: metadata records in, search/browse/subscribe out](./project/static/img/shtroverview.png) + + +## Parts +a look at the tangles of communication between different parts of the system: + +```mermaid +graph LR; + subgraph shtrove; + subgraph web[api/web server]; + ingest; + search; + browse; + rss; + atom; + oaipmh; + end; + worker["background worker (celery)"]; + indexer["indexer daemon"]; + rabbitmq["task queue (rabbitmq)"]; + postgres["database (postgres)"]; + elasticsearch; + web---rabbitmq; + web---postgres; + web---elasticsearch; + worker---rabbitmq; + worker---postgres; + worker---elasticsearch; + indexer---rabbitmq; + indexer---postgres; + indexer---elasticsearch; + end; + source["metadata source (e.g. osf.io backend)"]; + user["web user, either by browsing directly or via web app (like osf.io)"]; + subscribers["feed subscription tools"]; + source-->ingest; + user-->search; + user-->browse; + subscribers-->rss; + subscribers-->atom; + subscribers-->oaipmh; ``` ## Code map A brief look at important areas of code as they happen to exist now. -### Static configuration - -`share/schema/` describes the "normalized" metadata schema/format that all -metadata records are converted into when ingested. - -`share/sources/` describes a starting set of metadata sources that the system -could harvest metadata from -- these will be put in the database and can be -updated or added to over time. - -`project/settings.py` describes system-level settings which can be set by -environment variables (and their default values), as well as settings -which cannot. - -`share/models/` describes the data layer using the [Django](https://www.djangoproject.com/) ORM. - -`share/subjects.yaml` describes the "central taxonomy" of subjects allowed -in `Subject.name` fields of `NormalizedData`. - -### Harvest and ingest - -`share/harvest/` and `share/harvesters/` describe how metadata records -are pulled from other metadata repositories. - -`share/transform/` and `share/transformers/` describe how raw data (possibly -in any format) are transformed to the "normalized" schema. +- `trove`: django app for rdf-based apis + - `trove.digestive_tract`: most of what happens after ingestion + - stores records and identifiers in the database + - initiates indexing + - `trove.extract`: parsing ingested metadata records into resource descriptions + - `trove.derive`: from a given resource description, create special non-rdf serializations + - `trove.render`: from an api response modeled as rdf graph, render the requested mediatype + - `trove.models`: database models for identifiers and resource descriptions + - `trove.trovesearch`: builds rdf-graph responses for trove search apis (using `IndexStrategy` implementations from `share.search`) + - `trove.vocab`: identifies and describes concepts used elsewhere + - `trove.vocab.trove`: describes types, properties, and api paths in the trove api + - `trove.vocab.osfmap`: describes metadata from osf.io (currently the only metadata ingested) + - `trove.openapi`: generate openapi json for the trove api from thesaurus in `trove.vocab.trove` +- `share`: django app with search indexes and remnants of sharev2 + - `share.models`: database models for external sources, users, and other system book-keeping + - `share.oaipmh`: provide data via [OAI-PMH](https://www.openarchives.org/OAI/openarchivesprotocol.html) + - `share.search`: all interaction with elasticsearch + - `share.search.index_strategy`: abstract base class `IndexStrategy` with multiple implementations, for different approaches to indexing the same data + - `share.search.daemon`: the "indexer daemon", an optimized background worker for batch-processing updates and sending to all active index strategies + - `share.search.index_messenger`: for sending messages to the indexer daemon +- `api`: django app with remnants of the legacy sharev2 api + - `api.views.feeds`: allows custom RSS and Atom feeds + - otherwise, subject to possible deprecation +- `osf_oauth2_adapter`: django app for login via osf.io +- `project`: the actual django project + - default settings at `project.settings` + - pulls together code from other directories implemented as django apps (`share`, `trove`, `api`, and `osf_oauth2_adapter`) -`share/regulate/` describes rules which are applied to every normalized datum, -regardless where or what format it originally come from. -`share/metadata_formats/` describes how a normalized datum can be formatted -into any supported output format. - -`share/tasks/` runs the harvest/ingest pipeline and stores each task's status -(including debugging info, if errored) as a `HarvestJob` or `IngestJob`. - -### Outward-facing views - -`share/search/` describes how the search indexes are structured, managed, and -updated when new metadata records are introduced -- this provides a view for -discovering items based on whatever search criteria. - -`share/oaipmh/` describes the [OAI-PMH](https://www.openarchives.org/OAI/openarchivesprotocol.html) -view for harvesting metadata from SHARE/Trove in bulk. - -`api/` describes a mostly REST-ful API that's useful for inspecting records for -a specific item of interest. - -### Internals - -`share/admin/` is a Django-app for administrative access to the SHARE database -and pipeline logs - -`osf_oauth2_adapter/` is a Django app to support logging in to SHARE via OSF +## Cross-cutting concerns -### Testing +### Resource descriptions -`tests/` are tests. +Uses the [resource description framework](https://www.w3.org/TR/rdf11-primer/#section-Introduction): +- the content of each ingested metadata record is an rdf graph focused on a specific resource +- all api responses from `trove` views are (experimentally) modeled as rdf graphs, which may be rendered a variety of ways -## Cross-cutting concerns +### Identifiers -### Immutable metadata +Whenever feasible, use full URI strings to identify resources, concepts, types, and properties that may be exposed outwardly. -Metadata records at all stages of the pipeline (`RawDatum`, `NormalizedData`, -`FormattedMetadataRecord`) should be considered immutable -- any updates -result in a new record being created, not an old record being altered. +Prefer using open, standard, well-defined namespaces wherever possible ([DCAT](https://www.w3.org/TR/vocab-dcat-3/) is a good place to start; see `trove.vocab.namespaces` for others already in use). When app-specific concepts must be defined, use the `TROVE` namespace (`https://share.osf.io/vocab/2023/trove/`). -Multiple records which describe the same item/object are grouped by a -"source-unique identifier" or "suid" -- essentially a two-tuple -`(source, identifier)` that uniquely and persistently identifies an item in -the source repository. In most outward-facing views, default to showing only -the most recent record for each suid. +A notable exception (non-URI identifier) is the "source-unique identifier" or "suid" -- essentially a two-tuple `(source, identifier)` that uniquely and persistently identifies a metadata record in a source repository. This `identifier` may be any string value, provided by the external source. ### Conventions (an incomplete list) -- functions prefixed `pls_` ("please") are a request for something to happen +- local variables prefixed with underscore (to consistently distinguish between internal-only names and those imported/built-in) +- prefer full type annotations in python code, wherever reasonably feasible ## Why this? inspired by [this writeup](https://matklad.github.io/2021/02/06/ARCHITECTURE.md.html) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d14287ddb..9655ad7c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,16 @@ # CONTRIBUTING -TODO: how do we want to guide community contributors? +> note: this codebase is currently (and historically) rather entangled with [osf.io](https://osf.io), which has its shtrove at https://share.osf.io/trove -- stay tuned for more-reusable open-source libraries and tools that should be more accessible to community contribution -For now, if you're interested in contributing to SHARE/Trove, feel free to +For now, if you're interested in contributing to SHARE/trove, feel free to [open an issue on github](https://github.com/CenterForOpenScience/SHARE/issues) and start a conversation. + +## Requirements + +All new changes must pass the following checks with no errors: +- unit tests: `python -m pytest -x tests/` +- linting: `python -m flake8` +- static type-checking (on `trove/` code only, for now): `python -m mypy trove` + +All new changes should also avoid decreasing test coverage, when reasonably possible. diff --git a/README.md b/README.md index 27a21f903..b88554f4d 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,17 @@ -# SHARE/Trove +# SHARE/trove (aka SHARtrove, shtrove) -SHARE is creating a free, open dataset of research (meta)data. +> share (verb): to have or use in common. -> **Note**: SHARE’s open API tools and services help bring together scholarship distributed across research ecosystems for the purpose of greater discoverability. However, SHARE does not guarantee a complete aggregation of searched outputs. For this reason, SHARE results should not be used for methodological analyses, such as systematic reviews. +> trove (noun): a store of valuable or delightful things. -[![Coverage Status](https://coveralls.io/repos/github/CenterForOpenScience/SHARE/badge.svg?branch=develop)](https://coveralls.io/github/CenterForOpenScience/SHARE?branch=develop) +SHARE/trove (aka SHARtrove, shtrove) is is a service meant to store (meta)data you wish to keep and offer openly. -## Documentation +note: this codebase is currently rather entangled with [osf.io](https://osf.io), which has its shtrove at https://share.osf.io/trove -- stay tuned for more-reusable open-source libraries and tools for working with (meta)data -### What is this? -see [WHAT-IS-THIS-EVEN.md](./WHAT-IS-THIS-EVEN.md) +see [ARCHITECTURE.md](./ARCHITECTURE.md) for help navigating this codebase -### How can I use it? -see [how-to/use-the-api.md](./how-to/use-the-api.md) +see [CONTRIBUTING.md](./CONTRIBUTING.md) for info about contributing changes -### How do I navigate this codebase? -see [ARCHITECTURE.md](./ARCHITECTURE.md) - -### How do I run a copy locally? -see [how-to/run-locally.md](./how-to/run-locally.md) - - -## Running Tests - -### Unit test suite - - py.test - -### BDD Suite - - behave +see [how-to/use-the-api.md](./how-to/use-the-api.md) for help using the api to add and access (meta)data +see [how-to/run-locally.md](./how-to/run-locally.md) for help running a shtrove instance for local development diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..4b9d41b16 --- /dev/null +++ b/TODO.md @@ -0,0 +1,86 @@ +# TODO: +ways to better this mess + +## better shtrove api experience + +- better web-browsing experience + - when `Accept` header accepts html, use html regardless of query-params + - when query param `acceptMediatype` requests another mediatype, display on page in copy/pastable way + - exception: when given `withFileName`, download without html wrapping + - exception: `/trove/browse` should still give hypertext with clickable links + - include more explanatory docs (and better fill out those explanations) + - more helpful (less erratic) visual design + - in each html rendering of an api response, include a `
` for adding/editing/viewing query params +- better tsv/csv experience + - set default columns for `index-value-search` (and/or broadly improve `fields` handling) +- better turtle experience + - quoted literal graphs also turtle + - omit unnecessary `^^rdf:string` +- better jsonld experience + - provide `@context` (via header, at least) + - accept jsonld at `/trove/ingest` (or at each `ldp:inbox`...) + + +## modular packaging +move actually-helpful logic into separate packages that can be used and maintained independently of +any particular web app/api/framework (and then use those packages in shtrove and osf) + +- `osfmap`: standalone OSFMAP definition + - define osfmap properties and shapes (following DCTAP) in static tsv files + - use `tapshoes` (below) to generate docs and helpful utility functions + - may replace/simplify: + - `osf.metadata.osf_gathering.OSFMAP` (and related constants) + - `trove.vocab.osfmap` + - `trove.derive.osfmap_json` +- `tapshoes`: for using and packaging [tabular application profiles](https://dcmi.github.io/dctap/) in python + - take a set of tsv/csv files as input + - should support any valid DCTAP (aim to be worth community interest) + - initial/immediate use case `osfmap` + - generate more human-readable docs of properties and shapes/types + - validate a given record (rdf graph) against a profile + - serialize a valid record in a consistent/stable way (according to the profile) + - enable publishing "official" application profiles as installable python packages + - learn from and consider using prior dctap work: + - dctap-python: https://pypi.org/project/dctap/ + - loads tabular files into more immediately usable form + - tap2shacl: https://pypi.org/project/tap2shacl/ + - builds shacl constraints from application profile + - could then validate a given graph with pyshacl: https://pypi.org/project/pyshacl/ +- metadata record crosswalk/serialization + - given a record (as rdf graph) and application profile to which it conforms (like OSFMAP), offer: + - crosswalking to a standard vocab (DCAT, schema.org, ...) + - stable rdf serialization (json-ld, turtle, xml, ...) + - special bespoke serialization (datacite xml/json, oai_dc, ...) + - may replace/simplify: + - `osf.metadata.serializers` + - `trove.derive` +- `shtrove`: reusable package with the good parts of share/trove + - python api and command-line tools + - given application profile + - digestive tract with pluggable storage/indexing interfaces + - methods for ingest, search, browse, subscribe +- `django-shtrove`: django wrapper for `shtrove` functionality + - set application profile via django setting + - django models for storage, elasticsearch for indexing + - django views for ingest, search, browse, subscribe + + +## open web standards +- data catalog vocabulary (DCAT) https://www.w3.org/TR/vocab-dcat-3/ + - an appropriate (and better thought-thru) vocab for a lot of what shtrove does + - already used in some ways, but would benefit from adopting more thoroughly + - replace bespoke types (like `trove:Indexcard`) with better-defined dcat equivalents (like `dcat:CatalogRecord`) + - rename various properties/types/variables similarly + - "catalog" vs "index" + - "record" vs "card" + - replace checksum-iris with `spdx:checksum` (added in dcat 3) +- linked data notifications (LDN) https://www.w3.org/TR/ldn/ + - shtrove incidentally (partially) aligns with linked-data principles -- could lean into that + - replace `/trove/ingest` with one or more `ldp:inbox` urls + - trove index-card like an inbox containing current/past resource descriptions + ``` + <://osf.example/blarg> ldp:inbox <://shtrove.example/index-card/0000-00...> . + <://shtrove.example/index-card/0000-00...> ldp:contains <://shtrove.example/description/0000-00...> . + <://shtrove.example/description/0000-00...> foaf:primaryTopic <://osf.example/blarg> + ``` + (might consider renaming "index-card" for consistency/clarity) diff --git a/WHAT-IS-THIS-EVEN.md b/WHAT-IS-THIS-EVEN.md deleted file mode 100644 index 8dd64d7e1..000000000 --- a/WHAT-IS-THIS-EVEN.md +++ /dev/null @@ -1,42 +0,0 @@ -# "What is this, even?" - -Imagine a vast, public library full of the outputs and results of some scientific -research -- shelves full of articles, preprints, datasets, data analysis plans, -and so on. - -You can think of SHARE/Trove as that library's card catalog. - -## "...What is a card catalog?" - -A [card catalog](https://en.wikipedia.org/wiki/Card_catalog) is that weird, cool cabinet you might see at the front of a -library with a bunch of tiny drawers full of index cards -- each index card -contains information about some item on the library shelves. - -The card catalog is where you go when you want to: -- locate a specific item in the library -- discover items related to a specific topic, author, or other keywords -- make a new item easily discoverable by others - -## "OK but what 'library' is this?" -As of July 2021, SHARE/Trove contains metadata on over 4.5 million items originating from: -- [OSF](https://osf.io) (including OSF-hosted Registries and Preprint Providers) -- [REPEC](http://repec.org) -- [arXiv](https://arxiv.org) -- [ClinicalTrials.gov](https://clinicaltrials.gov) -- ...and more! - -Updates from OSF are reflected within seconds, while updates from third-party sources are -harvested once daily. - -## "How can I use it?" - -You can search the full SHARE/Trove catalog at -[share.osf.io/discover](https://share.osf.io/discover). - -Other search pages can also be built on SHARE/Trove, showing only a specific -collection of items. For example, [OSF Preprints](https://osf.io/preprints/discover) -and [OSF Registries](https://osf.io/registries/discover) show only registrations -and preprints, respectively, which are hosted on OSF infrastructure. - -To learn about using the API (instead of a user interface), see -[how-to/use-the-api.md](./how-to/use-the-api.md) diff --git a/how-to/add-a-source.rst b/how-to/add-a-source.rst deleted file mode 100644 index 8e31ea6ac..000000000 --- a/how-to/add-a-source.rst +++ /dev/null @@ -1,251 +0,0 @@ -.. _harvesters-and-transformers: - -Harvesters and Transformers -=========================== - -A `harvester` gathers raw data from a source using their API. - -A `transformer` takes the raw data gathered by a harvester and maps the fields to the defined :ref:`SHARE models `. - -Writing a Harvester and Transformer ------------------------------------ - -See the transformers and harvesters located in the ``share/transformers/`` and ``share/harvesters/`` directories for more examples of syntax and best practices. - -Adding a new source -""""""""""""""""""""" - -- Determine whether the source has an API to access their metadata -- Create a source folder at ``share/sources/{source name}`` - - Source names are typically the reversed domain name of the source, e.g. a source at ``http://example.com`` would have the name ``com.example`` -- Create a file named ``source.yaml`` in the source folder - - See :ref:`Writing a source.yaml file ` -- Determine whether the source makes their data available using the `OAI-PMH`_ protocol - - If the source is OAI see :ref:`Best practices for OAI sources ` -- Writing the harvester - - See :ref:`Best practices for writing a Harvester ` -- Writing the transformer - - See :ref:`Best practices for writing a Transformer ` -- Adding a sources's icon - - visit ``www.domain.com/favicon.ico`` and download the ``favicon.ico`` file - - place the favicon as ``icon.ico`` in the source folder -- Load the source - - To make the source available in your local SHARE, run ``./manage.py loadsources`` in the terminal - -.. _OAI-PMH: http://www.openarchives.org/OAI/openarchivesprotocol.html - - -.. _writing-yaml: - -Writing a source.yaml file -"""""""""""""""""""""""""" - -The ``source.yaml`` file contains information about the source itself, and one or more configs that describe how to harvest and transform data from that source. - -.. code-block:: yaml - - name: com.example - long_title: Example SHARE Source for Examples - home_page: http://example.com/ - user: sources.com.example - configs: - - label: com.example.oai - base_url: http://example.com/oai/ - harvester: oai - harvester_kwargs: - metadata_prefix: oai_datacite - rate_limit_allowance: 5 - rate_limit_period: 1 - transformer: org.datacite - transformer_kwargs: {} - -See the whitepaper_ for Source and SourceConfig tables for the available fields. - -.. _whitepaper: https://github.com/CenterForOpenScience/SHARE/blob/develop/whitepapers/Tables.md - -.. _oai-sources: - -Best practices for OAI sources -"""""""""""""""""""""""""""""" - -Sources that use OAI-PMH_ make it easy to harvest their metadata. - -- Set ``harvester: oai`` in the source config. -- Choose a metadata format to harvest. - - Use the ``ListMetadataFormats`` OAI verb to see what formats the source supports. - - Every OAI source supports ``oai_dc``, but they usually also support at least one other format that has richer, more structured data, like ``oai_datacite`` or ``mods``. - - Choose the format that seems to have the most useful data for SHARE, especially if a transformer for that format already exists. - - Choose ``oai_dc`` only as a last resort. -- Add ``metadata_prefix: {prefix}`` to the ``harvester_kwargs`` in the source config. -- If necessary, write a transformer for the chosen format. - - See :ref:`Best practices for writing a Transformer ` - - -.. _.gitignore: https://github.com/CenterForOpenScience/SHARE/blob/develop/.gitignore - - -.. _writing-harvesters: - -Best practices for writing a non-OAI Harvester -"""""""""""""""""""""""""""""""""""""""""""""" - -- The harvester should be defined in ``share/harvesters/{harvester name}.py``. -- When writing the harvester: - - Inherit from ``share.harvest.BaseHarvester`` - - Add the version of the harvester ``VERSION = 1`` - - Implement ``do_harvest(...)`` (and possibly additional helper functions) to make requests to the source and to yield the harvested records. - - Check to see if the data returned by the source is paginated. - - There will often be a resumption token to get the next page of results. - - Check to see if the source's API accepts a date range - - If the API does not then, if possible, check the date on each record returned and stop harvesting if the date on the record is older than the specified start date. -- Add the harvester to ``entry_points`` in ``setup.py`` - - e.g. ``'com.example = share.harvesters.com_example:ExampleHarvester',`` - - run ``python setup.py develop`` to make the harvester available in your local SHARE -- Test by :ref:`running the harvester ` - -.. _writing-transformers: - -Best practices for writing a non-OAI Transformer -"""""""""""""""""""""""""""""""""""""""""""""""" - -- The transformer should be defined in ``share/transformers/{transformer name}.py``. -- When writing the transformer: - - Determine what information from the source record should be stored as part of the ``CreativeWork`` :ref:`model ` (i.e. if the record clearly defines a title, description, contributors, etc.). - - Use the :ref:`chain transformer tools ` as necessary to correctly parse the raw data. - - Alternatively, implement ``share.transform.BaseTransformer`` to create a transformer from scratch. - - Utilize the ``Extra`` class - - Raw data that does not fit into a defined :ref:`share model ` should be stored here. - - Raw data that is otherwise altered in the transformer should also be stored here to ensure data integrity. -- Add the transformer to ``entry_points`` in ``setup.py`` - - e.g. ``'com.example = share.transformer.com_example:ExampleTransformer',`` - - run ``python setup.py develop`` to make the transformer available in your local SHARE -- Test by :ref:`running the transformer ` against raw data you have harvested. - -.. _chain-transformer: - -SHARE Chain Transformer -""""""""""""""""""""""" - -SHARE provides a set of tools for writing transformers, based on the idea of constructing chains for each field that lead from the root of the raw document to the data for that field. To write a chain transformer, add ``from share.transform.chain import links`` at the top of the file and make the transformer inherit ``share.transform.chain.ChainTransformer``. - - -.. code-block:: python - - from share.transform.chain import ctx, links, ChainTransformer, Parser - - - class CreativeWork(Parser): - title = ctx.title - - - class ExampleTransformer(ChainTransformer): - VERSION = 1 - root_parser = CreativeWork - - -- Concat - To combine list or singular elements into a flat list:: - - links.Concat(, ) - -.. _delegate-reference: - -- Delegate - To specify which class to use:: - - links.Delegate() - -- Join - To combine list elements into a single string:: - - links.Join(, joiner=' ') - - Elements are separated with the ``joiner``. - By default ``joiner`` is a newline. - -- Map - To designate the class used for each instance of a value found:: - - links.Map(links.Delegate(), ) - - See the :ref:`share models ` for what uses a through table (anything that sets ``through=``). - Uses the :ref:`Delegate ` tool. - -- Maybe - To transform data that is not consistently available:: - - links.Maybe(, '') - - Indexing further if the path exists:: - - links.Maybe(, '')[''] - - Nesting Maybe:: - - links.Maybe(links.Maybe(, '')[''], '') - - To avoid excessive nesting use the :ref:`Try link ` - -- OneOf - To specify two possible paths for a single value:: - - links.OneOf(, ) - -- ParseDate - To determine a date from a string:: - - links.ParseDate() - -- ParseLanguage - To determine the ISO language code (i.e. 'ENG') from a string (i.e. 'English'):: - - links.ParseLanguage() - - Uses pycountry_ package. - - .. _pycountry: https://pypi.python.org/pypi/pycountry - -- ParseName - To determine the parts of a name (i.e. first name) out of a string:: - - links.ParseName().first - - options:: - - first - last - middle - suffix - title - nickname - - Uses nameparser_ package. - - .. _nameparser: https://pypi.python.org/pypi/nameparser - -- RunPython - To run a defined python function:: - - links.RunPython('', , *args, **kwargs) - -- Static - To define a static field:: - - links.Static() - -- Subjects - To map a subject to the PLOS taxonomy based on defined mappings:: - - links.Subjects() - -.. _try-reference: - -- Try - To transform data that is not consistently available and may throw an exception:: - - links.Try() - -- XPath - To access data using xpath:: - - links.XPath(, "") diff --git a/how-to/run-locally.md b/how-to/run-locally.md index 99e4a523d..7d0e6eb05 100644 --- a/how-to/run-locally.md +++ b/how-to/run-locally.md @@ -1,14 +1,14 @@ # SHARE Quickstart or: How I Learned to Stop Worrying and Love the Dock -this guide guides you through setting up SHARE locally using Docker -for development and manual testing. +this guide guides you through setting up SHARE locally for development and manual testing +using the `docker-compose.yml` file included in this repository. this guide does NOT guide you to anything appropriate for the open Internet. ## pre-requisites -- [git](https://git-scm.com/) -- [docker](https://www.docker.com/) (including `docker-compose`) +- [git](https://git-scm.com/) or equivalent +- [docker](https://www.docker.com/) (including `docker-compose`) or equivalent ## getting a local SHARE running @@ -48,11 +48,11 @@ docker-compose run --rm --no-deps worker bash this will open a bash prompt within a temporary `worker` container -- from here we can run commands within SHARE's environment, including django's `manage.py` -from within that worker shell, use django's `migrate` command to set up tables in postgres: +from within that worker shell, use django's `migrate` command to create tables in postgres: ``` python manage.py migrate ``` -...and use `sharectl` to set up indexes in elasticsearch: +...and the `shtrove_search_setup` command to create indexes in elasticsearch: ``` python manage.py shtrove_search_setup --initial ``` diff --git a/how-to/use-the-api.md b/how-to/use-the-api.md index 2a220615b..7a89650d6 100644 --- a/how-to/use-the-api.md +++ b/how-to/use-the-api.md @@ -1,25 +1,29 @@ -# How to use the API +# how to use the api -(see [openapi docs](/trove/docs/openapi.html) for detail) +## searching and browsing -## Sample and search for index-cards +`GET /trove/index-card-search`: search for cards that identify and describe things -`GET /trove/index-card-search`: search index-cards +`GET /trove/index-value-search`: search for values (like identifiers) used on cards, which you can use in card-searches -`GET /trove/index-value-search`: search values for specific properties on index-cards +`GET /trove/browse?iri=...`: inquire about a thing you have already identified -## Posting index-cards +(see [openapi docs](/trove/docs/openapi.html) for detail and available parameters) + + +### Posting index-cards > NOTE: currently used only by other COS projects, not yet for public use, authorization required -`POST /trove/ingest?focus_iri=...&record_identifier=...`: +`POST /trove/ingest?focus_iri=...`: currently supports only `Content-Type: text/turtle` query params: - `focus_iri` (required): full iri of the focus resource, exactly as used in the request body -- `record_identifier` (required): a source-specific identifier for the metadata record (no format restrictions) -- sending another record with the same `record_identifier` is considered a full update (only the most recent is used) +- `record_identifier`: a source-specific identifier for the metadata record (if omitted, uses `focus_iri`) -- sending another record with the same `record_identifier` is considered a full update (only the most recent is used) - `nonurgent`: if present (regardless of value), ingestion may be given a lower priority -- recommended for bulk or background operations - `is_supplementary`: if present (regardless of value), this record's metadata will be added to all pre-existing index-cards from the same user with the same `focus_iri` (if any), but will not get an index-card of its own nor affect the last-updated timestamp (e.g. in OAI-PMH) of the index-cards it supplements + - note: supplementary records must have a different `record_identifier` from the primary records for the same focus - `expiration_date`: optional date (in format `YYYY-MM-DD`) when the record is no longer valid and should be removed ## Deleting index-cards @@ -32,4 +36,3 @@ query params: `/oaipmh` -- an implementation of the Open Access Initiative's [Protocol for Metadata Harvesting](https://www.openarchives.org/OAI/openarchivesprotocol.html), an open standard for harvesting metadata from open repositories. You can use this to list metadata in bulk, or query by a few simple parameters (date range or source). - diff --git a/project/static/img/shtroverview.png b/project/static/img/shtroverview.png new file mode 100644 index 0000000000000000000000000000000000000000..0c78c3ebc51dcc2a8ad327b4670c5d35fdb554a4 GIT binary patch literal 151721 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 literal 0 HcmV?d00001 From 1df0cca1ae465a0568f3319559af844bb2234b06 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Thu, 14 Aug 2025 14:00:40 -0400 Subject: [PATCH 03/20] fix: use logging config for celery worker, too --- project/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/project/settings.py b/project/settings.py index a29abf4ef..74b6aef90 100644 --- a/project/settings.py +++ b/project/settings.py @@ -326,6 +326,7 @@ def split(string, delim): RABBITMQ_HEARTBEAT_TIMEOUT = int(os.environ.get('RABBITMQ_HEARTBEAT_TIMEOUT', 60)) CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'amqp://{}:{}@{}:{}/{}'.format(RABBITMQ_USERNAME, RABBITMQ_PASSWORD, RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_VHOST)) +CELERY_WORKER_HIJACK_ROOT_LOGGER = False CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' CELERY_BEAT_SCHEDULE = { From e27d827abda6ca58cd5b6473e6e951a3646eb851 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Mon, 18 Aug 2025 10:08:12 -0400 Subject: [PATCH 04/20] clarify --- CONTRIBUTING.md | 12 +++++++----- README.md | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9655ad7c0..ca8dcf691 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,18 @@ # CONTRIBUTING -> note: this codebase is currently (and historically) rather entangled with [osf.io](https://osf.io), which has its shtrove at https://share.osf.io/trove -- stay tuned for more-reusable open-source libraries and tools that should be more accessible to community contribution +> note: this codebase is currently (and historically) rather entangled with [osf.io](https://osf.io), which has its shtrove at https://share.osf.io -- stay tuned for more-reusable open-source libraries and tools that should be more accessible to community contribution For now, if you're interested in contributing to SHARE/trove, feel free to [open an issue on github](https://github.com/CenterForOpenScience/SHARE/issues) and start a conversation. -## Requirements +## Required checks -All new changes must pass the following checks with no errors: -- unit tests: `python -m pytest -x tests/` +All changes must pass the following checks with no errors: - linting: `python -m flake8` - static type-checking (on `trove/` code only, for now): `python -m mypy trove` +- tests: `python -m pytest -x tests/` + - note: some tests require other services running -- if [using the provided docker-compose.yml](./how-to/run-locally.md), recommend running in the background (upping worker ups all: `docker compose up -d worker`) and executing tests from within one of the python containers (`indexer`, `worker`, or `web`): + `docker compose exec indexer python -m pytest -x tests/` -All new changes should also avoid decreasing test coverage, when reasonably possible. +All new changes should also avoid decreasing test coverage, when reasonably possible (currently checked on github pull requests). diff --git a/README.md b/README.md index b88554f4d..201adfc2b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ SHARE/trove (aka SHARtrove, shtrove) is is a service meant to store (meta)data you wish to keep and offer openly. -note: this codebase is currently rather entangled with [osf.io](https://osf.io), which has its shtrove at https://share.osf.io/trove -- stay tuned for more-reusable open-source libraries and tools for working with (meta)data +note: this codebase is currently (and historically) rather entangled with [osf.io](https://osf.io), which has its shtrove at https://share.osf.io -- stay tuned for more-reusable open-source libraries and tools for working with (meta)data see [ARCHITECTURE.md](./ARCHITECTURE.md) for help navigating this codebase From 48bb99d3a60a9319cee71c67151051440d1287f5 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 5 Sep 2025 11:54:37 -0400 Subject: [PATCH 05/20] chore: remove 'while True' because why not --- .../_common_trovesearch_tests.py | 12 +++++---- tests/share/test_oaipmh_trove.py | 11 ++++---- trove/render/jsonapi.py | 14 ++++------ trove/util/django.py | 26 +++++++++---------- 4 files changed, 29 insertions(+), 34 deletions(-) diff --git a/tests/share/search/index_strategy/_common_trovesearch_tests.py b/tests/share/search/index_strategy/_common_trovesearch_tests.py index 3d5f51e58..c7146a762 100644 --- a/tests/share/search/index_strategy/_common_trovesearch_tests.py +++ b/tests/share/search/index_strategy/_common_trovesearch_tests.py @@ -117,10 +117,10 @@ def test_cardsearch_pagination(self): })) self._index_indexcards(_cards) # gather all pages results: - _querystring: str = f'page[size]={_page_size}' + _querystring: str | None = f'page[size]={_page_size}' _result_iris: set[str] = set() _page_count = 0 - while True: + while _querystring is not None: _cardsearch_handle = self.index_strategy.pls_handle_cardsearch( CardsearchParams.from_querystring(_querystring), ) @@ -133,9 +133,11 @@ def test_cardsearch_pagination(self): _result_iris.update(_page_iris) _page_count += 1 _next_cursor = _cardsearch_handle.cursor.next_cursor() - if _next_cursor is None: - break - _querystring = urlencode({'page[cursor]': _next_cursor.as_queryparam_value()}) + _querystring = ( + urlencode({'page[cursor]': _next_cursor.as_queryparam_value()}) + if _next_cursor is not None + else None # done + ) self.assertEqual(_page_count, math.ceil(_total_count / _page_size)) self.assertEqual(_result_iris, _expected_iris) diff --git a/tests/share/test_oaipmh_trove.py b/tests/share/test_oaipmh_trove.py index 0bdd7df1b..330f1631b 100644 --- a/tests/share/test_oaipmh_trove.py +++ b/tests/share/test_oaipmh_trove.py @@ -232,11 +232,9 @@ def _assert_full_list(self, verb, params, request_method, expected_count, page_s pages = 0 count = 0 token = None - while True: - if token: - parsed = oai_request({'verb': verb, 'resumptionToken': token}, request_method) - else: - parsed = oai_request({'verb': verb, 'metadataPrefix': 'oai_dc', **params}, request_method) + next_params: dict[str, str] | None = {'verb': verb, 'metadataPrefix': 'oai_dc', **params} + while next_params is not None: + parsed = oai_request(next_params, request_method) page = parsed.xpath('//oai:header/oai:identifier', namespaces=NAMESPACES) pages += 1 count += len(page) @@ -245,9 +243,10 @@ def _assert_full_list(self, verb, params, request_method, expected_count, page_s token = token[0].text if token: assert len(page) == page_size + next_params = {'verb': verb, 'resumptionToken': token} else: assert len(page) <= page_size - break + next_params = None # done assert count == expected_count assert pages == math.ceil(expected_count / page_size) diff --git a/trove/render/jsonapi.py b/trove/render/jsonapi.py index e60fc2338..536e562bc 100644 --- a/trove/render/jsonapi.py +++ b/trove/render/jsonapi.py @@ -38,15 +38,11 @@ def _resource_ids_defaultdict() -> defaultdict[Any, str]: _prefix = str(time.time_ns()) - _ints = itertools.count() - - def _iter_ids() -> Iterator[str]: - while True: - _id = next(_ints) - yield f'{_prefix}-{_id}' - - _ids = _iter_ids() - return defaultdict(lambda: next(_ids)) + _infinite_ids = ( + f'{_prefix}-{_id}' + for _id in itertools.count() + ) + return defaultdict(_infinite_ids.__next__) @dataclasses.dataclass diff --git a/trove/util/django.py b/trove/util/django.py index 77cf184bd..9b79165ee 100644 --- a/trove/util/django.py +++ b/trove/util/django.py @@ -16,18 +16,16 @@ def pk_chunked(queryset: QuerySet, chunksize: int) -> Generator[list]: ''' _ordered_qs = queryset.order_by('pk') _prior_end_pk = None - while True: # for each chunk: - _qs = ( - _ordered_qs - if _prior_end_pk is None - else _ordered_qs.filter(pk__gt=_prior_end_pk) - ) + _chunk_qs: QuerySet | None = _ordered_qs + while _chunk_qs is not None: # for each chunk: # load primary key values only - _pks = list(_qs.values_list('pk', flat=True)[:chunksize]) - if not _pks: - break # done - _end_pk = _pks[-1] - if (_prior_end_pk is not None) and (_end_pk <= _prior_end_pk): - raise RuntimeError(f'sentinel pks not ascending?? got {_end_pk} after {_prior_end_pk}') - _prior_end_pk = _end_pk - yield _pks + _pks = list(_chunk_qs.values_list('pk', flat=True)[:chunksize]) + if _pks: + _end_pk = _pks[-1] + if (_prior_end_pk is not None) and (_end_pk <= _prior_end_pk): + raise RuntimeError(f'sentinel pks not ascending?? got {_end_pk} after {_prior_end_pk}') + yield _pks + _prior_end_pk = _end_pk + _chunk_qs = _ordered_qs.filter(pk__gt=_prior_end_pk) + else: + _chunk_qs = None # done From a3fb2b28bb57294391d18e92cf451d749182f49f Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 5 Sep 2025 15:06:23 -0400 Subject: [PATCH 06/20] when browsing in browser, get non-html as html - replace `trove.render._rendering` module with `trove.render.rendering` package (separate file for each rendering class) - make `ProtoRendering` an actual `typing.Protocol` and narrow types to only unicode `str` - add `trove.render.rendering.html_wrapped.HtmlWrappedRendering` that puts minimal `
...
` around an inner rendering - use `HtmlWrappedRendering` to wrap responses that aren't html or json, when `Accept` header allows html and query params omit `withFileName` - use mediatype constants more consistently (leaving off charset except for content-type header) --- tests/trove/render/_base.py | 6 +-- tests/trove/render/test_jsonapi_renderer.py | 2 +- tests/trove/render/test_jsonld_renderer.py | 2 +- .../trove/render/test_simple_csv_renderer.py | 2 +- .../trove/render/test_simple_json_renderer.py | 2 +- .../trove/render/test_simple_tsv_renderer.py | 2 +- tests/trove/render/test_turtle_renderer.py | 2 +- tests/trove/test_doctest.py | 2 + trove/render/__init__.py | 11 ++-- trove/render/_base.py | 6 +-- trove/render/_html.py | 11 +++- trove/render/_rendering.py | 47 ----------------- trove/render/_simple_trovesearch.py | 6 +-- trove/render/html_browse.py | 12 ++--- trove/render/rendering/__init__.py | 4 ++ trove/render/rendering/html_wrapped.py | 20 ++++++++ trove/render/rendering/proto.py | 16 ++++++ trove/render/rendering/simple.py | 17 +++++++ trove/render/rendering/streamable.py | 18 +++++++ trove/render/simple_csv.py | 5 +- trove/render/simple_json.py | 5 +- trove/views/_base.py | 2 +- trove/views/_responder.py | 50 +++++++++++++++---- trove/vocab/mediatypes.py | 20 +++++++- 24 files changed, 174 insertions(+), 96 deletions(-) delete mode 100644 trove/render/_rendering.py create mode 100644 trove/render/rendering/__init__.py create mode 100644 trove/render/rendering/html_wrapped.py create mode 100644 trove/render/rendering/proto.py create mode 100644 trove/render/rendering/simple.py create mode 100644 trove/render/rendering/streamable.py diff --git a/tests/trove/render/_base.py b/tests/trove/render/_base.py index 94b8f94a8..c550041cc 100644 --- a/tests/trove/render/_base.py +++ b/tests/trove/render/_base.py @@ -7,7 +7,7 @@ from trove.trovesearch.trovesearch_gathering import trovesearch_by_indexstrategy from trove.render._base import BaseRenderer -from trove.render._rendering import ProtoRendering +from trove.render.rendering import ProtoRendering from trove.vocab.namespaces import RDF from tests.trove._input_output_tests import BasicInputOutputTestCase from ._inputs import UNRENDERED_RDF, UNRENDERED_SEARCH_RDF, RdfCase @@ -66,9 +66,9 @@ def assert_outputs_equal(self, expected_output, actual_output) -> None: self._get_rendered_output(actual_output), ) - def _get_rendered_output(self, rendering: ProtoRendering): + def _get_rendered_output(self, rendering: ProtoRendering) -> str: # for now, they always iter strings (update if/when bytes are in play) - return ''.join(rendering.iter_content()) # type: ignore[arg-type] + return ''.join(map(str, rendering.iter_content())) class TrovesearchRendererTests(TroveRendererTests): diff --git a/tests/trove/render/test_jsonapi_renderer.py b/tests/trove/render/test_jsonapi_renderer.py index 9357c5ff6..992ade522 100644 --- a/tests/trove/render/test_jsonapi_renderer.py +++ b/tests/trove/render/test_jsonapi_renderer.py @@ -2,7 +2,7 @@ from unittest import mock from trove.render.jsonapi import RdfJsonapiRenderer -from trove.render._rendering import SimpleRendering +from trove.render.rendering import SimpleRendering from trove.vocab.namespaces import BLARG from . import _base diff --git a/tests/trove/render/test_jsonld_renderer.py b/tests/trove/render/test_jsonld_renderer.py index eef657f1d..b74d7389c 100644 --- a/tests/trove/render/test_jsonld_renderer.py +++ b/tests/trove/render/test_jsonld_renderer.py @@ -1,7 +1,7 @@ import json from trove.render.jsonld import RdfJsonldRenderer -from trove.render._rendering import SimpleRendering +from trove.render.rendering import SimpleRendering from ._inputs import BLARG from . import _base diff --git a/tests/trove/render/test_simple_csv_renderer.py b/tests/trove/render/test_simple_csv_renderer.py index ca06aa273..d4da76e5b 100644 --- a/tests/trove/render/test_simple_csv_renderer.py +++ b/tests/trove/render/test_simple_csv_renderer.py @@ -1,5 +1,5 @@ from trove.render.simple_csv import TrovesearchSimpleCsvRenderer -from trove.render._rendering import SimpleRendering +from trove.render.rendering import SimpleRendering from . import _base diff --git a/tests/trove/render/test_simple_json_renderer.py b/tests/trove/render/test_simple_json_renderer.py index 7f59c8a59..cd1d9bcf6 100644 --- a/tests/trove/render/test_simple_json_renderer.py +++ b/tests/trove/render/test_simple_json_renderer.py @@ -1,7 +1,7 @@ import json from trove.render.simple_json import TrovesearchSimpleJsonRenderer -from trove.render._rendering import SimpleRendering +from trove.render.rendering import SimpleRendering from trove.vocab.namespaces import BLARG from . import _base diff --git a/tests/trove/render/test_simple_tsv_renderer.py b/tests/trove/render/test_simple_tsv_renderer.py index 752493362..baa3ed5ec 100644 --- a/tests/trove/render/test_simple_tsv_renderer.py +++ b/tests/trove/render/test_simple_tsv_renderer.py @@ -1,5 +1,5 @@ from trove.render.simple_tsv import TrovesearchSimpleTsvRenderer -from trove.render._rendering import SimpleRendering +from trove.render.rendering import SimpleRendering from . import _base diff --git a/tests/trove/render/test_turtle_renderer.py b/tests/trove/render/test_turtle_renderer.py index 32f949278..306174a44 100644 --- a/tests/trove/render/test_turtle_renderer.py +++ b/tests/trove/render/test_turtle_renderer.py @@ -1,7 +1,7 @@ from primitive_metadata import primitive_rdf as rdf from trove.render.turtle import RdfTurtleRenderer -from trove.render._rendering import SimpleRendering +from trove.render.rendering import SimpleRendering from . import _base diff --git a/tests/trove/test_doctest.py b/tests/trove/test_doctest.py index 18c77a18b..a0b4b888e 100644 --- a/tests/trove/test_doctest.py +++ b/tests/trove/test_doctest.py @@ -4,6 +4,7 @@ import trove.util.frozen import trove.util.iris import trove.util.propertypath +import trove.vocab.mediatypes _DOCTEST_OPTIONFLAGS = ( doctest.ELLIPSIS @@ -15,6 +16,7 @@ trove.util.frozen, trove.util.iris, trove.util.propertypath, + trove.vocab.mediatypes, ) diff --git a/trove/render/__init__.py b/trove/render/__init__.py index c5bf699a1..27abfdf79 100644 --- a/trove/render/__init__.py +++ b/trove/render/__init__.py @@ -1,8 +1,7 @@ -from typing import Type - from django import http from trove import exceptions as trove_exceptions +from trove.vocab.mediatypes import strip_mediatype_parameters from ._base import BaseRenderer from .jsonapi import RdfJsonapiRenderer from .html_browse import RdfHtmlBrowseRenderer @@ -25,10 +24,6 @@ TrovesearchSimpleTsvRenderer, ) -RendersType = Type[ - BaseRenderer | RdfHtmlBrowseRenderer | RdfJsonapiRenderer | RdfTurtleRenderer | RdfJsonldRenderer | TrovesearchSimpleCsvRenderer | TrovesearchSimpleJsonRenderer | TrovesearchSimpleTsvRenderer -] - RENDERER_BY_MEDIATYPE = { _renderer_type.MEDIATYPE: _renderer_type for _renderer_type in RENDERERS @@ -42,7 +37,9 @@ def get_renderer_type(request: http.HttpRequest) -> type[BaseRenderer]: _requested_mediatype = request.GET.get('acceptMediatype') if _requested_mediatype: try: - _chosen_renderer_type = RENDERER_BY_MEDIATYPE[_requested_mediatype] + _chosen_renderer_type = RENDERER_BY_MEDIATYPE[ + strip_mediatype_parameters(_requested_mediatype) + ] except KeyError: raise trove_exceptions.CannotRenderMediatype(_requested_mediatype) else: diff --git a/trove/render/_base.py b/trove/render/_base.py index 49a3a52ec..9c6ddb5b0 100644 --- a/trove/render/_base.py +++ b/trove/render/_base.py @@ -13,7 +13,7 @@ from trove.vocab import mediatypes from trove.vocab.trove import TROVE_API_THESAURUS from trove.vocab.namespaces import namespaces_shorthand -from ._rendering import ProtoRendering, SimpleRendering +from .rendering import ProtoRendering, SimpleRendering @dataclasses.dataclass @@ -61,7 +61,7 @@ def render_document(self) -> ProtoRendering: except NotImplementedError: raise NotImplementedError(f'class "{type(self)}" must implement either `render_document` or `simple_render_document`') else: - return SimpleRendering( # type: ignore[return-value] # until ProtoRendering(typing.Protocol) with py3.12 + return SimpleRendering( mediatype=self.MEDIATYPE, rendered_content=_content, ) @@ -69,7 +69,7 @@ def render_document(self) -> ProtoRendering: @classmethod def render_error_document(cls, error: trove_exceptions.TroveError) -> ProtoRendering: # may override, but default to jsonapi - return SimpleRendering( # type: ignore[return-value] # until ProtoRendering(typing.Protocol) with py3.12 + return SimpleRendering( mediatype=mediatypes.JSONAPI, rendered_content=json.dumps( {'errors': [{ # https://jsonapi.org/format/#error-objects diff --git a/trove/render/_html.py b/trove/render/_html.py index 6daa1e037..98a404a95 100644 --- a/trove/render/_html.py +++ b/trove/render/_html.py @@ -5,6 +5,7 @@ from xml.etree.ElementTree import ( Element, SubElement, + tostring as etree_tostring, ) from typing import Any @@ -13,10 +14,12 @@ __all__ = ('HtmlBuilder',) +HTML_DOCTYPE = '' + @dataclasses.dataclass class HtmlBuilder: - given_root: Element + given_root: Element = dataclasses.field(default_factory=lambda: Element('html')) _: dataclasses.KW_ONLY _nested_elements: list[Element] = dataclasses.field(default_factory=list) _heading_depth: int = 0 @@ -67,3 +70,9 @@ def leaf(self, tag_name: str, *, text: str | None = None, attrs: dict | None = N _leaf_element.text = text.unicode_value elif text is not None: _leaf_element.text = text + + def as_html_doc(self) -> str: + return '\n'.join(( + HTML_DOCTYPE, + etree_tostring(self.root_element, encoding='unicode', method='html'), + )) diff --git a/trove/render/_rendering.py b/trove/render/_rendering.py deleted file mode 100644 index 0de9b015a..000000000 --- a/trove/render/_rendering.py +++ /dev/null @@ -1,47 +0,0 @@ -import abc -import dataclasses -from typing import Iterator, Generator - -from trove import exceptions as trove_exceptions - - -class ProtoRendering(abc.ABC): - '''base class for all renderings - - (TODO: typing.Protocol (when py3.12+)) - ''' - - @property - @abc.abstractmethod - def mediatype(self) -> str: - '''`mediatype`: required readable attribute - ''' - raise NotImplementedError - - @abc.abstractmethod - def iter_content(self) -> Iterator[str | bytes | memoryview]: - '''`iter_content`: (only) required method - ''' - yield from () - - -@dataclasses.dataclass -class SimpleRendering: # implements ProtoRendering - mediatype: str - rendered_content: str = '' - - def iter_content(self) -> Generator[str]: - yield self.rendered_content - - -@dataclasses.dataclass -class StreamableRendering: # implements ProtoRendering - mediatype: str - content_stream: Iterator[str | bytes | memoryview] - _started_already: bool = False - - def iter_content(self) -> Iterator[str | bytes | memoryview]: - if self._started_already: - raise trove_exceptions.CannotRenderStreamTwice - self._started_already = True - yield from self.content_stream diff --git a/trove/render/_simple_trovesearch.py b/trove/render/_simple_trovesearch.py index 36bc36c4b..1d65b06e6 100644 --- a/trove/render/_simple_trovesearch.py +++ b/trove/render/_simple_trovesearch.py @@ -9,7 +9,7 @@ from trove.vocab.jsonapi import JSONAPI_LINK_OBJECT from trove.vocab.namespaces import TROVE, RDF from ._base import BaseRenderer -from ._rendering import ProtoRendering, SimpleRendering +from .rendering import ProtoRendering, SimpleRendering if TYPE_CHECKING: from trove.util.json import JsonObject @@ -30,7 +30,7 @@ def simple_multicard_rendering(self, cards: Iterator[tuple[str, JsonObject]]) -> raise NotImplementedError def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRendering: - return SimpleRendering( # type: ignore[return-value] + return SimpleRendering( mediatype=self.MEDIATYPE, rendered_content=self.simple_unicard_rendering(card_iri, osfmap_json), ) @@ -41,7 +41,7 @@ def multicard_rendering(self, card_pages: Iterator[dict[str, JsonObject]]) -> Pr for _page in card_pages for _card_iri, _card_contents in _page.items() ) - return SimpleRendering( # type: ignore[return-value] + return SimpleRendering( mediatype=self.MEDIATYPE, rendered_content=self.simple_multicard_rendering(_cards), ) diff --git a/trove/render/html_browse.py b/trove/render/html_browse.py index 1f5bffd6f..b278b1fff 100644 --- a/trove/render/html_browse.py +++ b/trove/render/html_browse.py @@ -12,7 +12,6 @@ from urllib.parse import quote, urlsplit, urlunsplit from xml.etree.ElementTree import ( Element, - tostring as etree_tostring, fromstring as etree_fromstring, ) @@ -60,12 +59,10 @@ _PHI = (math.sqrt(5) + 1) / 2 -_HTML_DOCTYPE = '' - @dataclasses.dataclass class RdfHtmlBrowseRenderer(BaseRenderer): - MEDIATYPE: ClassVar[str] = 'text/html; charset=utf-8' + MEDIATYPE: ClassVar[str] = mediatypes.HTML __current_data: rdf.RdfTripleDictionary = dataclasses.field(init=False) __visiting_iris: set[str] = dataclasses.field(init=False) __hb: HtmlBuilder = dataclasses.field(init=False) @@ -82,7 +79,7 @@ def is_data_blended(self) -> bool | None: # override BaseRenderer def simple_render_document(self) -> str: - self.__hb = HtmlBuilder(Element('html')) + self.__hb = HtmlBuilder() self.render_html_head() _body_attrs = { 'class': 'BrowseWrapper', @@ -92,10 +89,7 @@ def simple_render_document(self) -> str: self.render_nav() self.render_main() self.render_footer() - return '\n'.join(( - _HTML_DOCTYPE, - etree_tostring(self.__hb.root_element, encoding='unicode', method='html'), - )) + return self.__hb.as_html_doc() def render_html_head(self) -> None: with self.__hb.nest('head'): diff --git a/trove/render/rendering/__init__.py b/trove/render/rendering/__init__.py new file mode 100644 index 000000000..029ca9f4c --- /dev/null +++ b/trove/render/rendering/__init__.py @@ -0,0 +1,4 @@ +from .proto import ProtoRendering +from .simple import SimpleRendering + +__all__ = ('ProtoRendering', 'SimpleRendering') diff --git a/trove/render/rendering/html_wrapped.py b/trove/render/rendering/html_wrapped.py new file mode 100644 index 000000000..360e09446 --- /dev/null +++ b/trove/render/rendering/html_wrapped.py @@ -0,0 +1,20 @@ +import dataclasses +import html +from typing import Iterator + +from trove.vocab import mediatypes +from trove.render._html import HTML_DOCTYPE +from .proto import ProtoRendering + + +@dataclasses.dataclass +class HtmlWrappedRendering(ProtoRendering): + inner_rendering: ProtoRendering + mediatype: str = mediatypes.HTML + + def iter_content(self) -> Iterator[str]: + yield HTML_DOCTYPE + yield '
'
+        for _content in self.inner_rendering.iter_content():
+            yield html.escape(_content)
+        yield '
' diff --git a/trove/render/rendering/proto.py b/trove/render/rendering/proto.py new file mode 100644 index 000000000..ac0269f94 --- /dev/null +++ b/trove/render/rendering/proto.py @@ -0,0 +1,16 @@ +from typing import ( + Iterator, + Protocol, +) + +__all__ = ('ProtoRendering',) + + +class ProtoRendering(Protocol): + '''protocol for all renderings + ''' + mediatype: str # required attribute + + def iter_content(self) -> Iterator[str]: + '''`iter_content`: (only) required method + ''' diff --git a/trove/render/rendering/simple.py b/trove/render/rendering/simple.py new file mode 100644 index 000000000..2300ababf --- /dev/null +++ b/trove/render/rendering/simple.py @@ -0,0 +1,17 @@ +from collections.abc import Generator +import dataclasses + +from .proto import ProtoRendering + +__all__ = ('SimpleRendering',) + + +@dataclasses.dataclass +class SimpleRendering(ProtoRendering): + '''for simple pre-rendered string content + ''' + mediatype: str + rendered_content: str = '' + + def iter_content(self) -> Generator[str]: + yield self.rendered_content diff --git a/trove/render/rendering/streamable.py b/trove/render/rendering/streamable.py new file mode 100644 index 000000000..4570a66be --- /dev/null +++ b/trove/render/rendering/streamable.py @@ -0,0 +1,18 @@ +from collections.abc import Iterator +import dataclasses + +from trove import exceptions as trove_exceptions +from .proto import ProtoRendering + + +@dataclasses.dataclass +class StreamableRendering(ProtoRendering): + mediatype: str + content_stream: Iterator[str] = iter(()) + _started_already: bool = False + + def iter_content(self) -> Iterator[str]: + if self._started_already: + raise trove_exceptions.CannotRenderStreamTwice + self._started_already = True + yield from self.content_stream diff --git a/trove/render/simple_csv.py b/trove/render/simple_csv.py index 52c9d700b..ad6dfee0c 100644 --- a/trove/render/simple_csv.py +++ b/trove/render/simple_csv.py @@ -20,7 +20,8 @@ from trove.vocab import osfmap from trove.vocab.namespaces import TROVE from ._simple_trovesearch import SimpleTrovesearchRenderer -from ._rendering import StreamableRendering, ProtoRendering +from .rendering import ProtoRendering +from .rendering.streamable import StreamableRendering if TYPE_CHECKING: from trove.util.trove_params import BasicTroveParams from trove.util.json import JsonValue, JsonObject @@ -47,7 +48,7 @@ def multicard_rendering(self, card_pages: Iterator[dict[str, JsonObject]]) -> Pr card_pages, trove_params=getattr(self.response_focus, 'search_params', None), ) - return StreamableRendering( # type: ignore[return-value] + return StreamableRendering( mediatype=self.MEDIATYPE, content_stream=csv_stream(self.CSV_DIALECT, _doc.header(), _doc.rows()), ) diff --git a/trove/render/simple_json.py b/trove/render/simple_json.py index 753d6ee6e..82350538d 100644 --- a/trove/render/simple_json.py +++ b/trove/render/simple_json.py @@ -11,7 +11,8 @@ ) from trove.vocab import mediatypes from trove.vocab.namespaces import TROVE, RDF -from ._rendering import StreamableRendering, ProtoRendering +from .rendering import ProtoRendering +from .rendering.streamable import StreamableRendering from ._simple_trovesearch import SimpleTrovesearchRenderer if typing.TYPE_CHECKING: from trove.util.json import JsonObject @@ -31,7 +32,7 @@ def simple_unicard_rendering(self, card_iri: str, osfmap_json: dict[str, typing. }, indent=2) def multicard_rendering(self, card_pages: typing.Iterator[dict[str, dict[str, typing.Any]]]) -> ProtoRendering: - return StreamableRendering( # type: ignore[return-value] + return StreamableRendering( mediatype=self.MEDIATYPE, content_stream=self._stream_json(card_pages), ) diff --git a/trove/views/_base.py b/trove/views/_base.py index 802aa56e2..cd2a0fcbd 100644 --- a/trove/views/_base.py +++ b/trove/views/_base.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from django.http import HttpResponse, StreamingHttpResponse, HttpRequest from trove.render import BaseRenderer - from trove.render._rendering import ProtoRendering + from trove.render.rendering import ProtoRendering __all__ = ( diff --git a/trove/views/_responder.py b/trove/views/_responder.py index 1d3365742..a0599e0f8 100644 --- a/trove/views/_responder.py +++ b/trove/views/_responder.py @@ -5,14 +5,21 @@ from django import http as djhttp from trove.render._base import BaseRenderer -from trove.render._rendering import ( - ProtoRendering, - StreamableRendering, -) +from trove.render.rendering import ProtoRendering +from trove.render.rendering.streamable import StreamableRendering +from trove.render.rendering.html_wrapped import HtmlWrappedRendering from trove.exceptions import TroveError from trove.vocab import mediatypes +_BROWSER_FRIENDLY_MEDIATYPES = { + mediatypes.HTML, + mediatypes.JSON, + mediatypes.JSONLD, + mediatypes.JSONAPI, +} + + def make_http_response( *, content_rendering: ProtoRendering, @@ -24,15 +31,26 @@ def make_http_response( if isinstance(content_rendering, StreamableRendering) else djhttp.HttpResponse ) + _download_filename = ( + http_request.GET.get('withFileName') + if http_request is not None + else None + ) + if ( + _download_filename is None + and content_rendering.mediatype not in _BROWSER_FRIENDLY_MEDIATYPES + and http_request is not None + and 'Accept' in http_request.headers + and http_request.accepts(mediatypes.HTML) + ): # when browsing in browser, return html (unless given filename) + content_rendering = HtmlWrappedRendering(content_rendering) _response = _response_type( content_rendering.iter_content(), - content_type=content_rendering.mediatype, + content_type=_make_content_type(content_rendering.mediatype), ) - if http_request is not None: - _requested_filename = http_request.GET.get('withFileName') - if _requested_filename is not None: - _file_name = _get_file_name(_requested_filename, content_rendering.mediatype) - _response.headers['Content-Disposition'] = _disposition(_file_name) + if _download_filename is not None: + _file_name = _get_file_name(_download_filename, content_rendering.mediatype) + _response.headers['Content-Disposition'] = _disposition(_file_name) return _response @@ -46,7 +64,7 @@ def make_http_error_response( return djhttp.HttpResponse( _content_rendering.iter_content(), status=error.http_status, - content_type=_content_rendering.mediatype, + content_type=_make_content_type(_content_rendering.mediatype), ) @@ -70,3 +88,13 @@ def _disposition(filename: str) -> bytes: b'filename=' + filename.encode('latin-1', errors='replace'), b"filename*=utf-8''" + filename.encode(), )) + + +def _make_content_type(mediatype: str) -> str: + """make a content-type header value from a mediatype + + currently just adds "charset=utf-8" to text mediatypes that don't already have one + """ + if mediatype.startswith('text/') and ('charset' not in mediatype): + return f'{mediatype};charset=utf-8' + return mediatype diff --git a/trove/vocab/mediatypes.py b/trove/vocab/mediatypes.py index 66495683a..71a1990f4 100644 --- a/trove/vocab/mediatypes.py +++ b/trove/vocab/mediatypes.py @@ -17,9 +17,27 @@ CSV: '.csv', } +_PARAMETER_DELIMITER = ';' + + +def strip_mediatype_parameters(mediatype: str) -> str: + """from a full mediatype that may have parameters, get only the base mediatype + + >>> strip_mediatype_parameters('text/plain;charset=utf-8') + 'text/plain' + >>> strip_mediatype_parameters('text/plain') + 'text/plain' + + note: does not validate that the mediatype exists or makes sense + >>> strip_mediatype_parameters('application/whatever ; blarg=foo') + 'application/whatever' + """ + (_base, _, __) = mediatype.partition(_PARAMETER_DELIMITER) + return _base.strip() + def dot_extension(mediatype: str) -> str: try: - return _file_extensions[mediatype] + return _file_extensions[strip_mediatype_parameters(mediatype)] except KeyError: raise ValueError(f'unrecognized mediatype: {mediatype}') From f6f285a0542006e5d75056ead3f6b9bb2a4f342f Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 5 Sep 2025 18:18:01 -0400 Subject: [PATCH 07/20] mess about with html rendering of shtrove api --- trove/render/_html.py | 13 +- trove/render/html_browse.py | 243 +++++++++++++++++++++------------ trove/static/css/browse.css | 41 ++++-- trove/trovebrowse_gathering.py | 15 +- trove/views/browse.py | 5 + trove/vocab/namespaces.py | 3 + trove/vocab/trove.py | 4 +- 7 files changed, 211 insertions(+), 113 deletions(-) diff --git a/trove/render/_html.py b/trove/render/_html.py index 98a404a95..3bded5288 100644 --- a/trove/render/_html.py +++ b/trove/render/_html.py @@ -7,7 +7,6 @@ SubElement, tostring as etree_tostring, ) -from typing import Any from primitive_metadata import primitive_rdf as rdf @@ -39,18 +38,16 @@ def _current_element(self) -> Element: # html-building helper methods @contextlib.contextmanager - def nest_h_tag(self, **kwargs: Any) -> Generator[Element]: + def deeper_heading(self) -> Generator[str]: _outer_heading_depth = self._heading_depth if not _outer_heading_depth: self._heading_depth = 1 elif _outer_heading_depth < 6: # h6 deepest self._heading_depth += 1 - _h_tag = f'h{self._heading_depth}' - with self.nest(_h_tag, **kwargs) as _nested: - try: - yield _nested - finally: - self._heading_depth = _outer_heading_depth + try: + yield f'h{self._heading_depth}' + finally: + self._heading_depth = _outer_heading_depth @contextlib.contextmanager def nest(self, tag_name: str, attrs: dict | None = None) -> Generator[Element]: diff --git a/trove/render/html_browse.py b/trove/render/html_browse.py index b278b1fff..dd8f947af 100644 --- a/trove/render/html_browse.py +++ b/trove/render/html_browse.py @@ -1,7 +1,4 @@ -from collections.abc import ( - Iterator, - Generator, -) +from collections.abc import Generator import contextlib import dataclasses import datetime @@ -26,7 +23,8 @@ from trove.util.iris import get_sufficiently_unique_iri from trove.util.randomness import shuffled from trove.vocab import mediatypes -from trove.vocab.namespaces import RDF, RDFS, SKOS, DCTERMS, FOAF, DC +from trove.vocab import jsonapi +from trove.vocab.namespaces import RDF, RDFS, SKOS, DCTERMS, FOAF, DC, OSFMAP from trove.vocab.static_vocab import combined_thesaurus__suffuniq from trove.vocab.trove import trove_browse_link from ._base import BaseRenderer @@ -49,11 +47,16 @@ DCTERMS.title, DC.title, FOAF.name, + OSFMAP.fileName, ) _IMPLICIT_DATATYPES = frozenset(( RDF.string, RDF.langString, )) +_PREDICATES_RENDERED_SPECIAL = frozenset(( + RDF.type, +)) +_PRIMITIVE_LITERAL_TYPES = (float, int, datetime.date) _QUERYPARAM_SPLIT_RE = re.compile(r'(?=[?&])') @@ -63,14 +66,14 @@ @dataclasses.dataclass class RdfHtmlBrowseRenderer(BaseRenderer): MEDIATYPE: ClassVar[str] = mediatypes.HTML - __current_data: rdf.RdfTripleDictionary = dataclasses.field(init=False) + __current_data: rdf.RdfGraph = dataclasses.field(init=False) __visiting_iris: set[str] = dataclasses.field(init=False) __hb: HtmlBuilder = dataclasses.field(init=False) __last_hue_turn: float = dataclasses.field(default_factory=random.random) def __post_init__(self) -> None: # TODO: lang (according to request -- also translate) - self.__current_data = self.response_tripledict + self.__current_data = self.response_data self.__visiting_iris = set() @property @@ -81,11 +84,13 @@ def is_data_blended(self) -> bool | None: def simple_render_document(self) -> str: self.__hb = HtmlBuilder() self.render_html_head() - _body_attrs = { - 'class': 'BrowseWrapper', - 'style': self._hue_turn_css(), - } - with self.__hb.nest('body', attrs=_body_attrs): + with ( + self._hue_turn_css() as _hue_turn_style, + self.__hb.nest('body', attrs={ + 'class': 'BrowseWrapper', + 'style': _hue_turn_style, + }), + ): self.render_nav() self.render_main() self.render_footer() @@ -147,67 +152,69 @@ def __mediatype_link(self, mediatype: str) -> None: with self.__hb.nest('a', attrs={'href': reverse('trove:docs')}) as _link: _link.text = _('(stable for documented use)') - def __render_subj(self, subj_iri: str, *, start_collapsed: bool | None = None) -> None: - _twopledict = self.__current_data.get(subj_iri, {}) - with self.__visiting(subj_iri): + def __render_subj(self, subj_iri: str, *, include_details: bool = True) -> None: + with self.__visiting(subj_iri) as _h_tag: with self.__nest_card('article'): with self.__hb.nest('header'): - _compact = self.iri_shorthand.compact_iri(subj_iri) - _is_compactable = (_compact != subj_iri) - _should_link = (subj_iri not in self.response_focus.iris) - with self.__hb.nest_h_tag(attrs={'id': quote(subj_iri)}) as _h: - if _should_link: - with self.__nest_link(subj_iri) as _link: - if _is_compactable: - _link.text = _compact - else: - self.__split_iri_pre(subj_iri) + with self.__hb.nest(_h_tag, attrs={'id': quote(subj_iri)}): + if self.__is_focus(subj_iri): + self.__split_iri_pre(subj_iri) else: - if _is_compactable: - _h.text = _compact - else: + with self.__nest_link(subj_iri): self.__split_iri_pre(subj_iri) self.__iri_subheaders(subj_iri) - if _twopledict: - with self.__hb.nest('details') as _details: - _detail_depth = sum((_el.tag == 'details') for _el in self.__hb._nested_elements) - _should_open = ( - _detail_depth < 3 - if start_collapsed is None - else not start_collapsed - ) - if _should_open: - _details.set('open', '') + if self.__is_focus(subj_iri): + self.__hb.leaf('pre', text=subj_iri) + if include_details and (_twopledict := self.__current_data.tripledict.get(subj_iri, {})): + _details_attrs = ( + {'open': ''} + if (self.__is_focus(subj_iri) or _is_local_url(subj_iri)) + else {} + ) + with self.__hb.nest('details', _details_attrs): self.__hb.leaf('summary', text=_('more details...')) self.__twoples(_twopledict) def __twoples(self, twopledict: rdf.RdfTwopleDictionary) -> None: with self.__hb.nest('dl', {'class': 'Browse__twopleset'}): - for _pred, _obj_set in shuffled(twopledict.items()): + for _pred, _obj_set in self.__order_twopledict(twopledict): with self.__hb.nest('dt', attrs={'class': 'Browse__predicate'}): self.__compact_link(_pred) for _text in self.__iri_thesaurus_labels(_pred): self.__literal(_text) with self.__hb.nest('dd'): - for _obj in shuffled(_obj_set): + for _obj in _obj_set: self.__obj(_obj) + def __order_twopledict(self, twopledict: rdf.RdfTwopleDictionary) -> Generator[tuple[str, list[rdf.RdfObject]]]: + _items_with_sorted_objs = ( + (_pred, sorted(_obj_set, key=_obj_ordering_key)) + for _pred, _obj_set in twopledict.items() + if _pred not in _PREDICATES_RENDERED_SPECIAL + ) + yield from sorted( + _items_with_sorted_objs, + key=lambda _item: _obj_ordering_key(_item[1][0]), + ) + def __obj(self, obj: rdf.RdfObject) -> None: if isinstance(obj, str): # iri # TODO: detect whether indexcard? - if (obj in self.__current_data) and (obj not in self.__visiting_iris): + if (obj in self.__current_data.tripledict) and (obj not in self.__visiting_iris): self.__render_subj(obj) else: with self.__hb.nest('article', attrs={'class': 'Browse__object'}): self.__iri_link_and_labels(obj) elif isinstance(obj, frozenset): # blanknode - if (RDF.type, RDF.Seq) in obj: + if _is_jsonapi_link_obj(obj): + self.__jsonapi_link_obj(obj) + elif _is_sequence_obj(obj): self.__sequence(obj) else: self.__blanknode(obj) elif isinstance(obj, rdf.Literal): self.__literal(obj, is_rdf_object=True) - elif isinstance(obj, (float, int, datetime.date)): + elif isinstance(obj, _PRIMITIVE_LITERAL_TYPES): self.__literal(rdf.literal(obj), is_rdf_object=True) elif isinstance(obj, rdf.QuotedGraph): self.__quoted_graph(obj) @@ -249,8 +256,16 @@ def __sequence(self, sequence_twoples: frozenset[rdf.RdfTwople]) -> None: self.__obj(_seq_obj) def __quoted_graph(self, quoted_graph: rdf.QuotedGraph) -> None: - with self.__quoted_data(quoted_graph.tripledict): - self.__render_subj(quoted_graph.focus_iri) # , start_collapsed=True) + _should_include_details = ( + self.__is_focus(quoted_graph.focus_iri) + or (( # primary topic of response focus + self.response_focus.single_iri(), + FOAF.primaryTopic, + quoted_graph.focus_iri, + ) in self.response_data) + ) + with self.__quoted_data(quoted_graph): + self.__render_subj(quoted_graph.focus_iri, include_details=_should_include_details) def __blanknode(self, blanknode: rdf.RdfTwopleDictionary | frozenset) -> None: _twopledict = ( @@ -258,28 +273,46 @@ def __blanknode(self, blanknode: rdf.RdfTwopleDictionary | frozenset) -> None: if isinstance(blanknode, dict) else rdf.twopledict_from_twopleset(blanknode) ) - with self.__hb.nest('details', attrs={ - 'open': '', - 'class': 'Browse__blanknode Browse__object', - 'style': self._hue_turn_css(), - }): - self.__hb.leaf('summary', text='(blank node)') + with ( + self._hue_turn_css() as _hue_turn_style, + self.__hb.nest('details', attrs={ + 'open': '', + 'class': 'Browse__blanknode Browse__object', + 'style': _hue_turn_style, + }), + ): + with self.__hb.nest('summary'): + for _type_iri in _twopledict.get(RDF.type, ()): + self.__compact_link(_type_iri) self.__twoples(_twopledict) + def __jsonapi_link_obj(self, twopleset: frozenset[rdf.RdfTwople]) -> None: + _iri = next( + (str(_obj) for (_pred, _obj) in twopleset if _pred == RDF.value), + '', + ) + _text = next( + (_obj.unicode_value for (_pred, _obj) in twopleset if _pred == jsonapi.JSONAPI_MEMBERNAME), + '', + ) + with self.__nest_link(_iri, attrs={'class': 'Browse__blanknode Browse__object'}) as _a: + _a.text = _('link: %(linktext)s') % {'linktext': _text} + def __split_iri_pre(self, iri: str) -> None: - self.__hb.leaf('pre', text='\n'.join(self.__iri_lines(iri))) + self.__hb.leaf('pre', text='\n'.join(self.__iri_display_lines(iri))) @contextlib.contextmanager - def __visiting(self, iri: str) -> Iterator[None]: + def __visiting(self, iri: str) -> Generator[str]: assert iri not in self.__visiting_iris self.__visiting_iris.add(iri) try: - yield + with self.__hb.deeper_heading() as _h_tag: + yield _h_tag finally: self.__visiting_iris.remove(iri) @contextlib.contextmanager - def __quoted_data(self, quoted_data: dict) -> Generator[None]: + def __quoted_data(self, quoted_data: rdf.RdfGraph) -> Generator[None]: _outer_data = self.__current_data _outer_visiting_iris = self.__visiting_iris self.__current_data = quoted_data @@ -295,27 +328,32 @@ def __iri_link_and_labels(self, iri: str) -> None: for _text in self.__iri_thesaurus_labels(iri): self.__literal(_text) - def __nest_link(self, iri: str) -> contextlib.AbstractContextManager[Element]: + def __nest_link(self, iri: str, attrs: dict[str, str] | None = None) -> contextlib.AbstractContextManager[Element]: _href = ( iri if _is_local_url(iri) else trove_browse_link(iri) ) - return self.__hb.nest('a', attrs={'href': _href}) + return self.__hb.nest('a', attrs={**(attrs or {}), 'href': _href}) def __compact_link(self, iri: str) -> Element: with self.__nest_link(iri) as _a: - _a.text = self.iri_shorthand.compact_iri(iri) + _a.text = ''.join(self.__iri_display_lines(iri)) return _a - def __nest_card(self, tag: str) -> contextlib.AbstractContextManager[Element]: - return self.__hb.nest( - tag, - attrs={ - 'class': 'Browse__card', - 'style': self._hue_turn_css(), - }, - ) + @contextlib.contextmanager + def __nest_card(self, tag: str) -> Generator[Element]: + with ( + self._hue_turn_css() as _hue_turn_style, + self.__hb.nest( + tag, + attrs={ + 'class': 'Browse__card', + 'style': _hue_turn_style, + }, + ) as _element, + ): + yield _element def __iri_thesaurus_labels(self, iri: str) -> list[str]: # TODO: consider requested language @@ -325,16 +363,21 @@ def __iri_thesaurus_labels(self, iri: str) -> list[str]: if _thesaurus_entry: for _pred in _LINK_TEXT_PREDICATES: _labels.update(_thesaurus_entry.get(_pred, ())) - _twoples = self.__current_data.get(iri) + _twoples = self.__current_data.tripledict.get(iri) if _twoples: for _pred in _LINK_TEXT_PREDICATES: _labels.update(_twoples.get(_pred, ())) return shuffled(_labels) - def _hue_turn_css(self) -> str: - _hue_turn = (self.__last_hue_turn + _PHI) % 1.0 + @contextlib.contextmanager + def _hue_turn_css(self) -> Generator[str]: + _prior_turn = self.__last_hue_turn + _hue_turn = (_prior_turn + _PHI) % 1.0 self.__last_hue_turn = _hue_turn - return f'--hue-turn: {_hue_turn}turn;' + try: + yield f'--hue-turn: {_hue_turn}turn;' + finally: + self.__last_hue_turn = _prior_turn def _queryparam_href(self, param_name: str, param_value: str | None) -> str: _base_url = self.response_focus.single_iri() @@ -358,26 +401,34 @@ def _queryparam_href(self, param_name: str, param_value: str | None) -> str: )) def __iri_subheaders(self, iri: str) -> None: - _type_iris = self.__current_data.get(iri, {}).get(RDF.type, ()) - if _type_iris: - for _type_iri in _type_iris: - self.__compact_link(_type_iri) + for _type_iri in self.__current_data.q(iri, RDF.type): + self.__compact_link(_type_iri) _labels = self.__iri_thesaurus_labels(iri) if _labels: for _label in _labels: self.__literal(_label) - def __iri_lines(self, iri: str) -> Iterator[str]: - (_scheme, _netloc, _path, _query, _fragment) = urlsplit(iri) - yield ( - f'://{_netloc}{_path}' - if _netloc - else f'{_scheme}:{_path}' - ) - if _query: - yield from filter(bool, _QUERYPARAM_SPLIT_RE.split(f'?{_query}')) - if _fragment: - yield f'#{_fragment}' + def __iri_display_lines(self, iri: str) -> Generator[str]: + _compact = self.iri_shorthand.compact_iri(iri) + if _compact != iri: + yield _compact + else: + (_scheme, _netloc, _path, _query, _fragment) = urlsplit(iri) + # first line with path + if _is_local_url(iri): + yield f'/{_path.lstrip('/')}' + elif _netloc: + yield f'://{_netloc}{_path}' + else: + yield f'{_scheme}:{_path}' + # query and fragment separate + if _query: + yield from filter(bool, _QUERYPARAM_SPLIT_RE.split(f'?{_query}')) + if _fragment: + yield f'#{_fragment}' + + def __is_focus(self, iri: str) -> bool: + return (iri in self.response_focus.iris) def _append_class(el: Element, element_class: str) -> None: @@ -389,3 +440,25 @@ def _append_class(el: Element, element_class: str) -> None: def _is_local_url(iri: str) -> bool: return iri.startswith(settings.SHARE_WEB_URL) + + +def _is_sequence_obj(obj: rdf.RdfObject) -> bool: + return ( + isinstance(obj, frozenset) + and (RDF.type, RDF.Seq) in obj + ) + + +def _is_jsonapi_link_obj(obj: rdf.RdfObject) -> bool: + return ( + isinstance(obj, frozenset) + and (RDF.type, jsonapi.JSONAPI_LINK_OBJECT) in obj + ) + + +def _obj_ordering_key(obj: rdf.RdfObject) -> tuple[bool, ...]: + return ( + not isinstance(obj, (rdf.Literal, *_PRIMITIVE_LITERAL_TYPES)), # literal values first + not isinstance(obj, str), # iris next + _is_jsonapi_link_obj(obj), # jsonapi link objects last + ) diff --git a/trove/static/css/browse.css b/trove/static/css/browse.css index 643bcfcf2..75adadddc 100644 --- a/trove/static/css/browse.css +++ b/trove/static/css/browse.css @@ -20,7 +20,7 @@ flex-wrap: wrap; gap: var(--gutter-1); margin: 0; - padding: 1rem; + padding: var(--gutter-2); min-height: 100vh; background-color: lch(var(--bg-luminance) var(--bg-chroma) var(--hue-turn)); } @@ -36,7 +36,7 @@ .Browse__card { display: flex; flex-direction: column; - padding: var(--gutter-2) var(--gutter-3); + padding: var(--gutter-3) var(--gutter-4); background-color: lch(var(--bg-luminance) var(--bg-chroma) var(--hue-turn)); border-color: lch(59% var(--bg-chroma) var(--hue-turn)); border-style: solid; @@ -44,10 +44,10 @@ border-block-start-width: var(--gutter-4); border-inline-end-width: 0; border-block-end-width: 0; - /* - border-start-end-radius: 1rem; - border-end-start-radius: 1rem; - */ +} + +.BrowseWrapper details > summary { + padding-left: var(--gutter-4); } .BrowseWrapper details > summary::before { @@ -65,16 +65,22 @@ .Browse__card > header { display: flex; flex-direction: row; - gap: var(--gutter-2); + flex-wrap: wrap; + gap: var(--gutter-3); align-items: baseline; - border-bottom: solid 1px rgba(0,0,0,0.382); - margin-bottom: var(--gutter-3); + padding-left: var(--gutter-3); } .Browse__card > header > :first-child { margin: 0; } +.Browse__card > header:not(:last-child) { + border-bottom: solid 1px rgba(0,0,0,0.382); + padding-bottom: var(--gutter-3); + margin-bottom: var(--gutter-3); +} + .Browse__card > footer { padding: var(--gutter-2); } @@ -86,7 +92,7 @@ dl.Browse__twopleset { [twople-obj] 1fr ; grid-auto-flow: row; - row-gap: var(--gutter-2); + row-gap: var(--gutter-3); margin: 0; padding: 0; } @@ -126,8 +132,7 @@ dl.Browse__twopleset > dd { .Browse__literal { display: flex; flex-direction: row; - gap: var(--gutter-3); - padding: var(--gutter-4); + gap: var(--gutter-5); } .Browse__literal > q { @@ -140,10 +145,18 @@ dl.Browse__twopleset > dd { .Browse__predicate { background-color: lch(from var(--bg-color-initial) 89% c var(--hue-turn)); - padding: var(--gutter-4); + padding: 0 var(--gutter-4); +} + +.Browse__predicate .Browse__literal { + padding: 0 var(--gutter-3); } .Browse__object { background-color: lch(from var(--bg-color-initial) 93% c var(--hue-turn)); - padding: var(--gutter-4); + padding: 0 var(--gutter-4); +} + +.Browse__object.Browse__blanknode { + background-color: lch(var(--bg-luminance) var(--bg-chroma) var(--hue-turn)); } diff --git a/trove/trovebrowse_gathering.py b/trove/trovebrowse_gathering.py index f8efb9a60..97fd3d2fc 100644 --- a/trove/trovebrowse_gathering.py +++ b/trove/trovebrowse_gathering.py @@ -39,14 +39,21 @@ def gather_cards_focused_on(focus: gather.Focus, *, blend_cards: bool) -> GathererGenerator: _identifier_qs = trove_db.ResourceIdentifier.objects.queryset_for_iris(focus.iris) _indexcard_qs = trove_db.Indexcard.objects.filter(focus_identifier_set__in=_identifier_qs) + _lrd_qs = ( + trove_db.LatestResourceDescription.objects + .filter(indexcard__in=_indexcard_qs) + .select_related('indexcard') + ) if blend_cards: - for _latest_resource_description in trove_db.LatestResourceDescription.objects.filter(indexcard__in=_indexcard_qs): - yield from rdf.iter_tripleset(_latest_resource_description.as_rdf_tripledict()) + for _resource_description in _lrd_qs: + yield from rdf.iter_tripleset(_resource_description.as_rdf_tripledict()) + yield (ns.FOAF.isPrimaryTopicOf, _resource_description.indexcard.get_iri()) else: - for _indexcard in _indexcard_qs: - _card_iri = _indexcard.get_iri() + for _resource_description in _lrd_qs: + _card_iri = _resource_description.indexcard.get_iri() yield (ns.FOAF.isPrimaryTopicOf, _card_iri) yield (_card_iri, ns.RDF.type, ns.TROVE.Indexcard) + yield (_card_iri, ns.TROVE.resourceMetadata, _resource_description.as_quoted_graph()) @trovebrowse.gatherer(ns.TROVE.thesaurusEntry) diff --git a/trove/views/browse.py b/trove/views/browse.py index 6739b53d7..e50b41721 100644 --- a/trove/views/browse.py +++ b/trove/views/browse.py @@ -47,6 +47,11 @@ def _default_include(cls): _ns.TROVE.usedAtPath, )) + def to_querydict(self): + _querydict = super().to_querydict() + _querydict['iri'] = self.iri + return _querydict + class BrowseIriView(GatheredTroveView): gathering_organizer = trovebrowse diff --git a/trove/vocab/namespaces.py b/trove/vocab/namespaces.py index c0ebf1cb6..db86e679c 100644 --- a/trove/vocab/namespaces.py +++ b/trove/vocab/namespaces.py @@ -47,6 +47,8 @@ SHAREv2 = rdf.IriNamespace('https://share.osf.io/vocab/2017/sharev2/') # for the OSF metadata application profile (TODO: update to resolvable URL, when there is one) OSFMAP = rdf.IriNamespace('https://osf.io/vocab/2022/') +# non-standard namespace used by OSF for datacite terms (resolves to datacite docs) +DATACITE = rdf.IriNamespace('https://schema.datacite.org/meta/kernel-4/#') # for identifying jsonapi concepts with linked anchors on the jsonapi spec (probably fine) JSONAPI = rdf.IriNamespace('https://jsonapi.org/format/1.1/#') @@ -58,6 +60,7 @@ 'jsonapi': JSONAPI, 'oai': OAI, 'oai_dc': OAI_DC, + 'datacite': DATACITE, } if __debug__: # blarg: a nothing namespace for examples and testing diff --git a/trove/vocab/trove.py b/trove/vocab/trove.py index 7dd6d1a9e..ac7ac7a51 100644 --- a/trove/vocab/trove.py +++ b/trove/vocab/trove.py @@ -48,7 +48,7 @@ def trove_browse_link(iri: str) -> str: _compact = namespaces_shorthand().compact_iri(iri) return urllib.parse.urljoin( reverse('trove:browse-iri'), - f'?iri={urllib.parse.quote(_compact)}', + f'?blendCards&iri={urllib.parse.quote(_compact)}', ) @@ -494,7 +494,7 @@ def trove_browse_link(iri: str) -> str: unstable mediatypes (may change or sometimes respond 500): -* `text/html;charset=utf-8`: rdf as browsable html +* `text/html`: rdf as browsable html * `text/turtle`: rdf as [turtle](https://www.w3.org/TR/turtle/) * `application/ld+json`: rdf as [json-ld](https://www.w3.org/TR/json-ld11/) From bed1b2a16a1f0f3a03f7823a185d412fda15724f Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 19 Sep 2025 09:00:19 -0400 Subject: [PATCH 08/20] update TODO.md --- TODO.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index 4b9d41b16..6a3834d4b 100644 --- a/TODO.md +++ b/TODO.md @@ -4,13 +4,11 @@ ways to better this mess ## better shtrove api experience - better web-browsing experience - - when `Accept` header accepts html, use html regardless of query-params - - when query param `acceptMediatype` requests another mediatype, display on page in copy/pastable way - - exception: when given `withFileName`, download without html wrapping - - exception: `/trove/browse` should still give hypertext with clickable links - include more explanatory docs (and better fill out those explanations) - - more helpful (less erratic) visual design + - even more helpful (less erratic) visual design - in each html rendering of an api response, include a `` for adding/editing/viewing query params + - in browsable html, replace json literals with rdf rendered like the rest of the page + - (perf) add bare-minimal IndexcardDeriver (iris, types, namelikes); use for search-result display - better tsv/csv experience - set default columns for `index-value-search` (and/or broadly improve `fields` handling) - better turtle experience From 04c15723e5bbd51d8f8193b26685ce9288a44a9a Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Tue, 30 Sep 2025 09:15:55 -0400 Subject: [PATCH 09/20] fix: render >1 result in csv/tsv/json value-search - stream pages as lists, not dicts with colliding keys - tidy types - move `iter_unique` to `trove.util.iter` and add tests --- tests/trove/test_doctest.py | 2 ++ trove/render/_simple_trovesearch.py | 32 ++++++++--------- trove/render/simple_csv.py | 40 +++++++--------------- trove/render/simple_json.py | 19 ++++++---- trove/trovebrowse_gathering.py | 2 +- trove/trovesearch/trovesearch_gathering.py | 2 +- trove/util/iter.py | 19 ++++++++++ 7 files changed, 64 insertions(+), 52 deletions(-) create mode 100644 trove/util/iter.py diff --git a/tests/trove/test_doctest.py b/tests/trove/test_doctest.py index a0b4b888e..06baf8993 100644 --- a/tests/trove/test_doctest.py +++ b/tests/trove/test_doctest.py @@ -3,6 +3,7 @@ import trove.util.chainmap import trove.util.frozen import trove.util.iris +import trove.util.iter import trove.util.propertypath import trove.vocab.mediatypes @@ -15,6 +16,7 @@ trove.util.chainmap, trove.util.frozen, trove.util.iris, + trove.util.iter, trove.util.propertypath, trove.vocab.mediatypes, ) diff --git a/trove/render/_simple_trovesearch.py b/trove/render/_simple_trovesearch.py index 1d65b06e6..657e5b169 100644 --- a/trove/render/_simple_trovesearch.py +++ b/trove/render/_simple_trovesearch.py @@ -1,6 +1,8 @@ from __future__ import annotations -from collections.abc import Generator, Iterator +from collections.abc import Generator, Iterator, Sequence +import itertools import json +import logging from typing import Any, TYPE_CHECKING from primitive_metadata import primitive_rdf as rdf @@ -13,6 +15,8 @@ if TYPE_CHECKING: from trove.util.json import JsonObject +_logger = logging.getLogger(__name__) + class SimpleTrovesearchRenderer(BaseRenderer): '''for "simple" search api responses (including only result metadata) @@ -35,12 +39,8 @@ def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRend rendered_content=self.simple_unicard_rendering(card_iri, osfmap_json), ) - def multicard_rendering(self, card_pages: Iterator[dict[str, JsonObject]]) -> ProtoRendering: - _cards = ( - (_card_iri, _card_contents) - for _page in card_pages - for _card_iri, _card_contents in _page.items() - ) + def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: + _cards = itertools.chain.from_iterable(card_pages) return SimpleRendering( mediatype=self.MEDIATYPE, rendered_content=self.simple_multicard_rendering(_cards), @@ -57,7 +57,7 @@ def render_document(self) -> ProtoRendering: ) raise trove_exceptions.UnsupportedRdfType(_focustypes) - def _iter_card_pages(self) -> Generator[dict[str, JsonObject]]: + def _iter_card_pages(self) -> Generator[list[tuple[str, JsonObject]]]: assert not self.__already_iterated_cards self.__already_iterated_cards = True self._page_links = set() @@ -67,22 +67,22 @@ def _iter_card_pages(self) -> Generator[dict[str, JsonObject]]: if (RDF.type, JSONAPI_LINK_OBJECT) in _page: self._page_links.add(_page) elif rdf.is_container(_page): - _cardpage = [] - for _search_result in rdf.container_objects(_page): + _cardpage: list[tuple[str, JsonObject]] = [] + for _search_result_blanknode in rdf.container_objects(_page): try: _card = next( _obj - for _pred, _obj in _search_result + for _pred, _obj in _search_result_blanknode if _pred == TROVE.indexCard ) except StopIteration: pass # skip malformed else: - _cardpage.append(_card) - yield { - self._get_card_iri(_card): self._get_card_content(_card, _page_graph) - for _card in _cardpage - } + _cardpage.append(( + self._get_card_iri(_card), + self._get_card_content(_card, _page_graph), + )) + yield _cardpage def _get_card_iri(self, card: str | rdf.RdfBlanknode) -> str: return card if isinstance(card, str) else '' diff --git a/trove/render/simple_csv.py b/trove/render/simple_csv.py index ad6dfee0c..a67935335 100644 --- a/trove/render/simple_csv.py +++ b/trove/render/simple_csv.py @@ -2,19 +2,20 @@ from collections.abc import ( Generator, Iterator, - Iterable, Sequence, ) import csv +import dataclasses import functools import itertools -import dataclasses +import logging from typing import TYPE_CHECKING, ClassVar from trove.trovesearch.search_params import ( CardsearchParams, ValuesearchParams, ) +from trove.util.iter import iter_unique from trove.util.propertypath import Propertypath, GLOB_PATHSTEP from trove.vocab import mediatypes from trove.vocab import osfmap @@ -26,6 +27,7 @@ from trove.util.trove_params import BasicTroveParams from trove.util.json import JsonValue, JsonObject +_logger = logging.getLogger(__name__) type Jsonpath = Sequence[str] # path of json keys type CsvValue = str | int | float | None @@ -41,9 +43,10 @@ class TrovesearchSimpleCsvRenderer(SimpleTrovesearchRenderer): CSV_DIALECT: ClassVar[type[csv.Dialect]] = csv.excel def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRendering: - return self.multicard_rendering(card_pages=iter([{card_iri: osfmap_json}])) + _page = [(card_iri, osfmap_json)] + return self.multicard_rendering(card_pages=iter([_page])) - def multicard_rendering(self, card_pages: Iterator[dict[str, JsonObject]]) -> ProtoRendering: + def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: _doc = TabularDoc( card_pages, trove_params=getattr(self.response_focus, 'search_params', None), @@ -67,7 +70,7 @@ def csv_stream( @dataclasses.dataclass class TabularDoc: - card_pages: Iterator[dict[str, JsonObject]] + card_pages: Iterator[Sequence[tuple[str, JsonObject]]] trove_params: BasicTroveParams | None = None _started: bool = False @@ -79,10 +82,6 @@ def column_jsonpaths(self) -> tuple[Jsonpath, ...]: ) return (_ID_JSONPATH, *_column_jsonpaths) - @functools.cached_property - def first_page(self) -> dict[str, JsonObject]: - return next(self.card_pages, {}) - def _column_paths(self) -> Iterator[Propertypath]: _pathlists: list[Sequence[Propertypath]] = [] if self.trove_params is not None: # hacks @@ -103,29 +102,16 @@ def _column_paths(self) -> Iterator[Propertypath]: _pathlists.append(_pathlist) if not _pathlists: _pathlists.append(osfmap.DEFAULT_TABULAR_SEARCH_COLUMN_PATHS) - return self.iter_unique(itertools.chain.from_iterable(_pathlists)) - - @staticmethod - def iter_unique[T](iterable: Iterable[T]) -> Generator[T]: - _seen = set() - for _item in iterable: - if _item not in _seen: - _seen.add(_item) - yield _item - - def _iter_card_pages(self) -> Generator[dict[str, JsonObject]]: - assert not self._started - self._started = True - if self.first_page: - yield self.first_page - yield from self.card_pages + return iter_unique(itertools.chain.from_iterable(_pathlists)) def header(self) -> list[CsvValue]: return ['.'.join(_path) for _path in self.column_jsonpaths] def rows(self) -> Generator[list[CsvValue]]: - for _page in self._iter_card_pages(): - for _card_iri, _osfmap_json in _page.items(): + assert not self._started + self._started = True + for _page in self.card_pages: + for _card_iri, _osfmap_json in _page: yield self._row_values(_osfmap_json) def _row_values(self, osfmap_json: JsonObject) -> list[CsvValue]: diff --git a/trove/render/simple_json.py b/trove/render/simple_json.py index 82350538d..a29025d37 100644 --- a/trove/render/simple_json.py +++ b/trove/render/simple_json.py @@ -15,6 +15,11 @@ from .rendering.streamable import StreamableRendering from ._simple_trovesearch import SimpleTrovesearchRenderer if typing.TYPE_CHECKING: + from collections.abc import ( + Generator, + Iterator, + Sequence, + ) from trove.util.json import JsonObject @@ -24,25 +29,25 @@ class TrovesearchSimpleJsonRenderer(SimpleTrovesearchRenderer): MEDIATYPE = mediatypes.JSON INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] - def simple_unicard_rendering(self, card_iri: str, osfmap_json: dict[str, typing.Any]) -> str: + def simple_unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> str: return json.dumps({ 'data': self._render_card_content(card_iri, osfmap_json), 'links': self._render_links(), 'meta': self._render_meta(), }, indent=2) - def multicard_rendering(self, card_pages: typing.Iterator[dict[str, dict[str, typing.Any]]]) -> ProtoRendering: + def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: return StreamableRendering( mediatype=self.MEDIATYPE, content_stream=self._stream_json(card_pages), ) - def _stream_json(self, card_pages: typing.Iterator[dict[str, typing.Any]]) -> typing.Generator[str]: + def _stream_json(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> Generator[str]: _prefix = '{"data": [' yield _prefix _datum_prefix = None for _page in card_pages: - for _card_iri, _osfmap_json in _page.items(): + for _card_iri, _osfmap_json in _page: if _datum_prefix is not None: yield _datum_prefix yield json.dumps(self._render_card_content(_card_iri, _osfmap_json), indent=2) @@ -79,7 +84,7 @@ def _render_meta(self) -> dict[str, int | str]: pass return _meta - def _render_links(self) -> dict[str, typing.Any]: + def _render_links(self) -> JsonObject: _links = {} for _pagelink in self._page_links: _twopledict = rdf.twopledict_from_twopleset(_pagelink) @@ -89,8 +94,8 @@ def _render_links(self) -> dict[str, typing.Any]: _links[_membername.unicode_value] = _link_url return _links - def _add_twople(self, json_dict: dict[str, typing.Any], predicate_iri: str, object_iri: str) -> None: - _obj_ref = {'@id': object_iri} + def _add_twople(self, json_dict: JsonObject, predicate_iri: str, object_iri: str) -> None: + _obj_ref: JsonObject = {'@id': object_iri} _obj_list = json_dict.setdefault(predicate_iri, []) if isinstance(_obj_list, list): _obj_list.append(_obj_ref) diff --git a/trove/trovebrowse_gathering.py b/trove/trovebrowse_gathering.py index 97fd3d2fc..8145ed9ef 100644 --- a/trove/trovebrowse_gathering.py +++ b/trove/trovebrowse_gathering.py @@ -46,7 +46,7 @@ def gather_cards_focused_on(focus: gather.Focus, *, blend_cards: bool) -> Gather ) if blend_cards: for _resource_description in _lrd_qs: - yield from rdf.iter_tripleset(_resource_description.as_rdf_tripledict()) + yield from rdf.iter_tripleset(_resource_description.as_rdfdoc_with_supplements().tripledict) yield (ns.FOAF.isPrimaryTopicOf, _resource_description.indexcard.get_iri()) else: for _resource_description in _lrd_qs: diff --git a/trove/trovesearch/trovesearch_gathering.py b/trove/trovesearch/trovesearch_gathering.py index 14138cbf0..f10006920 100644 --- a/trove/trovesearch/trovesearch_gathering.py +++ b/trove/trovesearch/trovesearch_gathering.py @@ -40,7 +40,7 @@ ) -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) type GathererGenerator = Generator[rdf.RdfTriple | rdf.RdfTwople] diff --git a/trove/util/iter.py b/trove/util/iter.py new file mode 100644 index 000000000..414febee5 --- /dev/null +++ b/trove/util/iter.py @@ -0,0 +1,19 @@ +from collections.abc import ( + Generator, + Hashable, + Iterable, +) + + +def iter_unique[T: Hashable](iterable: Iterable[T]) -> Generator[T]: + ''' + >>> list(iter_unique([1,1,1])) + [1] + >>> list(iter_unique([1,2,3,2,4,2,1,5])) + [1, 2, 3, 4, 5] + ''' + _seen = set() + for _item in iterable: + if _item not in _seen: + _seen.add(_item) + yield _item From c61666b69f9d6cb748b21bc65f4354dfc11485cf Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Wed, 1 Oct 2025 14:21:34 -0400 Subject: [PATCH 10/20] add cardsearch feeds (rss and atom) - add feed renderers: - trove.render.cardsearch_rss - trove.render.cardsearch_atom - add feed paths: - /trove/index-card-search/rss.xml - /trove/index-card-search/atom.xml - add links from cardsearch responses to feeds (with appropriate params) - miscellaneous improvements: - better renderer test coverage - consolidate more shared logic into trove.util - more accurate type annotations --- api/middleware.py | 2 +- api/views/feeds.py | 6 +- project/settings.py | 1 + share/oaipmh/indexcard_repository.py | 15 +-- share/oaipmh/response_renderer.py | 3 +- share/oaipmh/util.py | 12 --- share/util/fromisoformat.py | 10 -- share/util/xml.py | 2 +- .../index_strategy/_with_real_services.py | 6 -- tests/share/test_oaipmh_trove.py | 2 +- tests/trove/_input_output_tests.py | 12 +-- tests/trove/digestive_tract/test_expel.py | 6 -- tests/trove/render/_base.py | 3 +- tests/trove/render/_inputs.py | 32 ++++++- .../render/test_cardsearch_atom_renderer.py | 48 ++++++++++ .../render/test_cardsearch_rss_renderer.py | 50 ++++++++++ .../trove/render/test_html_browse_renderer.py | 31 ++++++ tests/trove/render/test_jsonapi_renderer.py | 31 +++++- tests/trove/render/test_jsonld_renderer.py | 33 +++++-- .../trove/render/test_simple_csv_renderer.py | 2 +- .../trove/render/test_simple_json_renderer.py | 43 +++++---- .../trove/render/test_simple_tsv_renderer.py | 2 +- tests/trove/render/test_turtle_renderer.py | 8 +- trove/derive/oaidc_xml.py | 3 +- trove/links.py | 58 +++++++++++ trove/openapi.py | 2 +- trove/render/__init__.py | 8 +- trove/render/_base.py | 2 +- trove/render/_html.py | 75 --------------- trove/render/_simple_trovesearch.py | 20 ++-- trove/render/cardsearch_atom.py | 71 ++++++++++++++ trove/render/cardsearch_rss.py | 61 ++++++++++++ trove/render/html_browse.py | 22 ++--- trove/render/jsonapi.py | 72 ++++++++------ trove/render/rendering/html_wrapped.py | 4 +- trove/render/rendering/proto.py | 2 +- trove/render/rendering/simple.py | 4 +- trove/render/rendering/streamable.py | 4 +- trove/render/simple_csv.py | 68 +++---------- trove/render/simple_json.py | 21 ++-- trove/trovesearch/page_cursor.py | 1 - trove/trovesearch/search_handle.py | 4 +- trove/trovesearch/search_params.py | 2 + trove/trovesearch/trovesearch_gathering.py | 13 +++ trove/urls.py | 14 ++- trove/util/datetime.py | 18 ++++ trove/util/html.py | 43 +++++++++ trove/util/json.py | 95 ++++++++++++++++++- trove/util/xml.py | 66 +++++++++++++ trove/views/_base.py | 9 +- trove/views/_responder.py | 2 + trove/views/feeds.py | 48 ++++++++++ trove/vocab/mediatypes.py | 4 + trove/vocab/trove.py | 10 -- 54 files changed, 869 insertions(+), 317 deletions(-) delete mode 100644 share/util/fromisoformat.py create mode 100644 tests/trove/render/test_cardsearch_atom_renderer.py create mode 100644 tests/trove/render/test_cardsearch_rss_renderer.py create mode 100644 tests/trove/render/test_html_browse_renderer.py create mode 100644 trove/links.py delete mode 100644 trove/render/_html.py create mode 100644 trove/render/cardsearch_atom.py create mode 100644 trove/render/cardsearch_rss.py create mode 100644 trove/util/datetime.py create mode 100644 trove/util/html.py create mode 100644 trove/util/xml.py create mode 100644 trove/views/feeds.py diff --git a/api/middleware.py b/api/middleware.py index a27e1c2a4..72a7f82d7 100644 --- a/api/middleware.py +++ b/api/middleware.py @@ -27,7 +27,7 @@ def process_view(self, request, view_func, view_args, view_kwargs): if settings.HIDE_DEPRECATED_VIEWS and deprecation_level == DeprecationLevel.HIDDEN: return HttpResponse( - f'This path ({request.path}) has been removed. If you have built something that relies on it, please email us at share-support@osf.io', + f'This path ({request.path}) has been removed. If you have built something that relies on it, please email us at {settings.SHARE_SUPPORT_EMAIL}', status=410, ) diff --git a/api/views/feeds.py b/api/views/feeds.py index 85925591f..40378d1f8 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -1,3 +1,4 @@ +import datetime from xml.sax.saxutils import unescape import json import logging @@ -10,7 +11,6 @@ from share.search import index_strategy from share.search.exceptions import IndexStrategyError from share.util.xml import strip_illegal_xml_chars -from share.util.fromisoformat import fromisoformat logger = logging.getLogger(__name__) @@ -108,10 +108,10 @@ def item_author_name(self, item): return prepare_string('{}{}'.format(author_name, ' et al.' if len(authors) > 1 else '')) def item_pubdate(self, item): - return fromisoformat(item.get('date_published') or item.get('date_created')) + return datetime.datetime.fromisoformat(item.get('date_published') or item.get('date_created')) def item_updateddate(self, item): - return fromisoformat(item.get(self._order)) + return datetime.datetime.fromisoformat(item.get(self._order)) def item_categories(self, item): categories = item.get('subjects', []) diff --git a/project/settings.py b/project/settings.py index 96fa1d00d..95fed6109 100644 --- a/project/settings.py +++ b/project/settings.py @@ -445,6 +445,7 @@ def route_urgent_task(name, args, kwargs, options, task=None, **kw): PUBLIC_SENTRY_DSN = os.environ.get('PUBLIC_SENTRY_DSN') SHARE_WEB_URL = os.environ.get('SHARE_WEB_URL', 'http://localhost:8003').rstrip('/') + '/' +SHARE_SUPPORT_EMAIL = os.environ.get('SHARE_SUPPORT_EMAIL', 'share-support@cos.io') SHARE_USER_AGENT = os.environ.get('SHARE_USER_AGENT', 'SHAREbot/{} (+{})'.format(VERSION, SHARE_WEB_URL)) SHARE_ADMIN_USERNAME = os.environ.get('SHARE_ADMIN_USERNAME', 'admin') SHARE_ADMIN_PASSWORD = os.environ.get('SHARE_ADMIN_PASSWORD') diff --git a/share/oaipmh/indexcard_repository.py b/share/oaipmh/indexcard_repository.py index d9d855f75..72a3ee407 100644 --- a/share/oaipmh/indexcard_repository.py +++ b/share/oaipmh/indexcard_repository.py @@ -1,15 +1,16 @@ +import datetime import uuid from django.core.exceptions import ValidationError as DjangoValidationError +from django.conf import settings from django.db.models import OuterRef, Subquery, F from share.oaipmh import errors as oai_errors from share.oaipmh.verbs import OAIVerb from share.oaipmh.response_renderer import OAIRenderer -from share.oaipmh.util import format_datetime -from share.util.fromisoformat import fromisoformat from share import models as share_db from trove import models as trove_db +from trove.util.datetime import datetime_isoformat_z as format_datetime from trove.vocab.namespaces import OAI_DC @@ -18,7 +19,7 @@ class OaiPmhRepository: REPOSITORY_IDENTIFIER = 'share.osf.io' IDENTIFER_DELIMITER = ':' GRANULARITY = 'YYYY-MM-DD' - ADMIN_EMAILS = ['share-support@osf.io'] + ADMIN_EMAILS = [settings.SHARE_SUPPORT_EMAIL] # TODO better way of structuring this than a bunch of dictionaries? # this dictionary's keys are `metadataPrefix` values @@ -206,7 +207,7 @@ def _get_indexcard_page_queryset(self, kwargs, catch=True, last_id=None): ) if 'from' in kwargs: try: - _from = fromisoformat(kwargs['from']) + _from = datetime.datetime.fromisoformat(kwargs['from']) except ValueError: if not catch: raise @@ -217,7 +218,7 @@ def _get_indexcard_page_queryset(self, kwargs, catch=True, last_id=None): ) if 'until' in kwargs: try: - _until = fromisoformat(kwargs['until']) + _until = datetime.datetime.fromisoformat(kwargs['until']) except ValueError: if not catch: raise @@ -291,12 +292,12 @@ def _get_resumption_token(self, kwargs, last_id): _until = None if 'from' in kwargs: try: - _from = fromisoformat(kwargs['from']) + _from = datetime.datetime.fromisoformat(kwargs['from']) except ValueError: self.errors.append(oai_errors.BadArgument('Invalid value for', 'from')) if 'until' in kwargs: try: - _until = fromisoformat(kwargs['until']) + _until = datetime.datetime.fromisoformat(kwargs['until']) except ValueError: self.errors.append(oai_errors.BadArgument('Invalid value for', 'until')) _set_spec = kwargs.get('set', '') diff --git a/share/oaipmh/response_renderer.py b/share/oaipmh/response_renderer.py index c45aea770..c8e233e0a 100644 --- a/share/oaipmh/response_renderer.py +++ b/share/oaipmh/response_renderer.py @@ -4,7 +4,8 @@ from django.urls import reverse -from share.oaipmh.util import format_datetime, SubEl, ns, nsmap +from share.oaipmh.util import SubEl, ns, nsmap +from trove.util.datetime import datetime_isoformat_z as format_datetime class OAIRenderer: diff --git a/share/oaipmh/util.py b/share/oaipmh/util.py index 413ac0173..a7457d4ef 100644 --- a/share/oaipmh/util.py +++ b/share/oaipmh/util.py @@ -1,23 +1,11 @@ -import datetime from typing import Any from lxml import etree from primitive_metadata import primitive_rdf -from share.util.fromisoformat import fromisoformat from trove.vocab.namespaces import OAI, OAI_DC -def format_datetime(dt: datetime.datetime | primitive_rdf.Literal | str) -> str: - """OAI-PMH has specific time format requirements -- comply. - """ - if isinstance(dt, primitive_rdf.Literal): - dt = dt.unicode_value - if isinstance(dt, str): - dt = fromisoformat(dt) - return dt.strftime('%Y-%m-%dT%H:%M:%SZ') - - XML_NAMESPACES = { 'dc': 'http://purl.org/dc/elements/1.1/', 'oai': str(OAI), diff --git a/share/util/fromisoformat.py b/share/util/fromisoformat.py deleted file mode 100644 index 92ac3d4a8..000000000 --- a/share/util/fromisoformat.py +++ /dev/null @@ -1,10 +0,0 @@ -import datetime -import re - - -def fromisoformat(date_str: str) -> datetime.datetime: - # wrapper around `datetime.datetime.fromisoformat` that supports "Z" UTC suffix - # (may be removed in python 3.11+, when `fromisoformat` handles more iso-6801 formats) - return datetime.datetime.fromisoformat( - re.sub('Z$', '+00:00', date_str), # replace "Z" shorthand with explicit timezone offset - ) diff --git a/share/util/xml.py b/share/util/xml.py index d0979954c..6ff13f829 100644 --- a/share/util/xml.py +++ b/share/util/xml.py @@ -15,5 +15,5 @@ ) -def strip_illegal_xml_chars(string): +def strip_illegal_xml_chars(string: str) -> str: return RE_XML_ILLEGAL.sub('', string) diff --git a/tests/share/search/index_strategy/_with_real_services.py b/tests/share/search/index_strategy/_with_real_services.py index a4219b312..ec4076668 100644 --- a/tests/share/search/index_strategy/_with_real_services.py +++ b/tests/share/search/index_strategy/_with_real_services.py @@ -48,12 +48,6 @@ def tearDown(self): connections['default']._test_serialized_contents ) - def enterContext(self, context_manager): - # TestCase.enterContext added in python3.11 -- implementing here until then - result = context_manager.__enter__() - self.addCleanup(lambda: context_manager.__exit__(None, None, None)) - return result - @contextlib.contextmanager def _daemon_up(self): _daemon_control = IndexerDaemonControl(celery_app) diff --git a/tests/share/test_oaipmh_trove.py b/tests/share/test_oaipmh_trove.py index 330f1631b..64b0e0b93 100644 --- a/tests/share/test_oaipmh_trove.py +++ b/tests/share/test_oaipmh_trove.py @@ -8,8 +8,8 @@ import pytest from share import models as share_db -from share.oaipmh.util import format_datetime from trove import models as trove_db +from trove.util.datetime import datetime_isoformat_z as format_datetime from trove.vocab.namespaces import OAI_DC from tests import factories diff --git a/tests/trove/_input_output_tests.py b/tests/trove/_input_output_tests.py index 90590fda9..72ec269f6 100644 --- a/tests/trove/_input_output_tests.py +++ b/tests/trove/_input_output_tests.py @@ -28,12 +28,12 @@ def assert_outputs_equal(self, expected_output: typing.Any, actual_output: typin self.assertEqual(expected_output, actual_output) # (optional override, for when logic is more complicated) - def run_input_output_test(self, given_input, expected_output): + def run_input_output_test(self, given_input: typing.Any, expected_output: typing.Any) -> None: _actual_output = self.compute_output(given_input) self.assert_outputs_equal(expected_output, _actual_output) # (optional override, for when logic is more complicated) - def missing_case(self, name: str, given_input): + def missing_case(self, name: str, given_input: typing.Any) -> typing.Never: _cls = self.__class__ _actual_output = self.compute_output(given_input) raise NotImplementedError('\n'.join(( @@ -43,16 +43,10 @@ def missing_case(self, name: str, given_input): pprint.pformat(_actual_output), ))) - def enterContext(self, context_manager): - # TestCase.enterContext added in python3.11 -- implementing here until then - result = context_manager.__enter__() - self.addCleanup(lambda: context_manager.__exit__(None, None, None)) - return result - ### # private details - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs: typing.Any) -> None: super().__init_subclass__(**kwargs) # HACK: assign `test_*` method only on concrete subclasses, # so the test runner doesn't try instantiating a base class diff --git a/tests/trove/digestive_tract/test_expel.py b/tests/trove/digestive_tract/test_expel.py index 7f2345eb2..333280a80 100644 --- a/tests/trove/digestive_tract/test_expel.py +++ b/tests/trove/digestive_tract/test_expel.py @@ -40,12 +40,6 @@ def setUp(self): def _replacement_notify_indexcard_update(self, indexcards, **kwargs): self.notified_indexcard_ids.update(_card.id for _card in indexcards) - def enterContext(self, context_manager): - # TestCase.enterContext added in python3.11 -- implementing here until then - result = context_manager.__enter__() - self.addCleanup(lambda: context_manager.__exit__(None, None, None)) - return result - def test_setup(self): self.indexcard_1.refresh_from_db() self.indexcard_2.refresh_from_db() diff --git a/tests/trove/render/_base.py b/tests/trove/render/_base.py index c550041cc..7e5b59ab9 100644 --- a/tests/trove/render/_base.py +++ b/tests/trove/render/_base.py @@ -1,4 +1,5 @@ import json +import typing from primitive_metadata import ( gather, @@ -56,7 +57,7 @@ def compute_output(self, given_input: RdfCase): ) return _renderer.render_document() - def assert_outputs_equal(self, expected_output, actual_output) -> None: + def assert_outputs_equal(self, expected_output: typing.Any, actual_output: typing.Any) -> None: if expected_output is None: print(repr(actual_output)) raise NotImplementedError diff --git a/tests/trove/render/_inputs.py b/tests/trove/render/_inputs.py index 29d6cb9ad..3ca9c9151 100644 --- a/tests/trove/render/_inputs.py +++ b/tests/trove/render/_inputs.py @@ -29,7 +29,7 @@ class RdfCase: DCTERMS.issued: {rdf.literal(datetime.date(2024, 1, 1))}, DCTERMS.modified: {rdf.literal(datetime.date(2024, 1, 1))}, TROVE.resourceMetadata: {rdf.literal( - json.dumps({'@id': BLARG.anItem, 'title': 'an item, yes'}), + json.dumps({'@id': BLARG.anItem, 'title': [{'@value': 'an item, yes'}]}), datatype_iris=RDF.JSON, )}, }, @@ -83,7 +83,7 @@ class RdfCase: DCTERMS.issued: {rdf.literal(datetime.date(2024, 1, 1))}, DCTERMS.modified: {rdf.literal(datetime.date(2024, 1, 1))}, TROVE.resourceMetadata: {rdf.literal( - json.dumps({'@id': BLARG.anItem, 'title': 'an item, yes'}), + json.dumps({'@id': BLARG.anItem, 'title': [{'@value': 'an item, yes'}]}), datatype_iris=RDF.JSON, )}, }, @@ -94,7 +94,7 @@ class RdfCase: DCTERMS.issued: {rdf.literal(datetime.date(2024, 2, 2))}, DCTERMS.modified: {rdf.literal(datetime.date(2024, 2, 2))}, TROVE.resourceMetadata: {rdf.literal( - json.dumps({'@id': BLARG.anItemm, 'title': 'an itemm, yes'}), + json.dumps({'@id': BLARG.anItemm, 'title': [{'@value': 'an itemm, yes'}]}), datatype_iris=RDF.JSON, )}, }, @@ -105,7 +105,31 @@ class RdfCase: DCTERMS.issued: {rdf.literal(datetime.date(2024, 3, 3))}, DCTERMS.modified: {rdf.literal(datetime.date(2024, 3, 3))}, TROVE.resourceMetadata: {rdf.literal( - json.dumps({'@id': BLARG.anItemmm, 'title': 'an itemmm, yes'}), + json.dumps({ + '@id': BLARG.anItemmm, + "sameAs": [ + {"@id": "https://doi.example/13.0/anItemmm"} + ], + 'title': [{'@value': 'an itemmm, yes'}], + "creator": [ + { + "@id": BLARG.aPerson, + "resourceType": [ + {"@id": "Agent"}, + {"@id": "Person"} + ], + "identifier": [ + {"@value": BLARG.aPerson} + ], + "name": [ + {"@value": "a person indeed"} + ] + } + ], + "dateCreated": [ + {"@value": "2001-02-03"} + ], + }), datatype_iris=RDF.JSON, )}, }, diff --git a/tests/trove/render/test_cardsearch_atom_renderer.py b/tests/trove/render/test_cardsearch_atom_renderer.py new file mode 100644 index 000000000..97b47dbf9 --- /dev/null +++ b/tests/trove/render/test_cardsearch_atom_renderer.py @@ -0,0 +1,48 @@ +from trove.render.cardsearch_atom import CardsearchAtomRenderer +from trove.render.rendering import SimpleRendering +from . import _base + + +# note: cardsearch only -- this renderer doesn't do arbitrary rdf + +class TestCardsearchAtomRenderer(_base.TrovesearchRendererTests): + renderer_class = CardsearchAtomRenderer + expected_outputs = { + 'no_results': SimpleRendering( + mediatype='application/atom+xml', + rendered_content=( + b"\n" + b'' + b'shtrove search results' + b'feed of metadata records matching given filters' + b'http://blarg.example/vocab/aSearch' + b'http://blarg.example/vocab/aSearch' + b'' + ), + ), + 'few_results': SimpleRendering( + mediatype='application/atom+xml', + rendered_content=( + b"\n" + b'' + b'shtrove search results' + b'feed of metadata records matching given filters' + b'http://blarg.example/vocab/aSearchFew' + b'http://blarg.example/vocab/aSearchFew' + b'' + b'' + b'http://blarg.example/vocab/aCard' + b'an item, yes' + b'' + b'' + b'http://blarg.example/vocab/aCardd' + b'an itemm, yes' + b'' + b'' + b'http://blarg.example/vocab/aCarddd' + b'an itemmm, yes' + b'2001-02-03T00:00:00Z' + b'' + ), + ), + } diff --git a/tests/trove/render/test_cardsearch_rss_renderer.py b/tests/trove/render/test_cardsearch_rss_renderer.py new file mode 100644 index 000000000..0c1f65f79 --- /dev/null +++ b/tests/trove/render/test_cardsearch_rss_renderer.py @@ -0,0 +1,50 @@ +from trove.render.cardsearch_rss import CardsearchRssRenderer +from trove.render.rendering import SimpleRendering +from . import _base + + +# note: cardsearch only -- this renderer doesn't do arbitrary rdf + +class TestCardsearchRssRenderer(_base.TrovesearchRendererTests): + renderer_class = CardsearchRssRenderer + expected_outputs = { + 'no_results': SimpleRendering( + mediatype='application/rss+xml', + rendered_content=( + b"\n" + b'' + b'' + b'shtrove search results' + b'http://blarg.example/vocab/aSearch' + b'feed of metadata records matching given filters' + b'share-support@cos.io' + b'' + ), + ), + 'few_results': SimpleRendering( + mediatype='application/rss+xml', + rendered_content=( + b"\n" + b'' + b'shtrove search results' + b'http://blarg.example/vocab/aSearchFew' + b'feed of metadata records matching given filters' + b'share-support@cos.io' + b'' + b'http://blarg.example/vocab/anItem' + b'http://blarg.example/vocab/anItem' + b'an item, yes' + b'' + b'http://blarg.example/vocab/anItemm' + b'http://blarg.example/vocab/anItemm' + b'an itemm, yes' + b'' + b'http://blarg.example/vocab/anItemmm' + b'http://blarg.example/vocab/anItemmm' + b'an itemmm, yes' + b'Sat, 03 Feb 2001 00:00:00 -0000' + b'http://blarg.example/vocab/aPerson (a person indeed)' + b'' + ), + ), + } diff --git a/tests/trove/render/test_html_browse_renderer.py b/tests/trove/render/test_html_browse_renderer.py new file mode 100644 index 000000000..2f4229376 --- /dev/null +++ b/tests/trove/render/test_html_browse_renderer.py @@ -0,0 +1,31 @@ +import html +import typing + +from trove.render.html_browse import RdfHtmlBrowseRenderer +from . import _base + + +# note: smoke tests only (TODO: better) + +class TestCardsearchRssRenderer(_base.TrovesearchRendererTests): + renderer_class = RdfHtmlBrowseRenderer + expected_outputs = { + 'no_results': { + 'mediatype': 'text/html', + 'result_iris': [], + }, + 'few_results': { + 'mediatype': 'text/html', + 'result_iris': [ + 'http://blarg.example/vocab/anItem', + 'http://blarg.example/vocab/anItemm', + 'http://blarg.example/vocab/anItemmm', + ], + }, + } + + def assert_outputs_equal(self, expected_output: typing.Any, actual_output: typing.Any) -> None: + self.assertEqual(actual_output.mediatype, expected_output['mediatype']) + # smoke tests -- instead of asserting full rendered html page, just check the results are in there + for _result_iri in expected_output['result_iris']: + self.assertIn(html.escape(_result_iri), actual_output.rendered_content) diff --git a/tests/trove/render/test_jsonapi_renderer.py b/tests/trove/render/test_jsonapi_renderer.py index 992ade522..dfdcd4f93 100644 --- a/tests/trove/render/test_jsonapi_renderer.py +++ b/tests/trove/render/test_jsonapi_renderer.py @@ -43,7 +43,7 @@ class TestJsonapiRenderer(_BaseJsonapiRendererTest): ], "resourceMetadata": { "@id": BLARG.anItem, - "title": "an item, yes" + "title": [{"@value": "an item, yes"}] } }, "links": { @@ -189,7 +189,7 @@ class TestJsonapiSearchRenderer(_BaseJsonapiRendererTest, _base.TrovesearchJsonR ], "resourceMetadata": { "@id": BLARG.anItem, - "title": "an item, yes" + "title": [{"@value": "an item, yes"}] } }, "links": { @@ -215,8 +215,29 @@ class TestJsonapiSearchRenderer(_BaseJsonapiRendererTest, _base.TrovesearchJsonR BLARG.anItemmm ], "resourceMetadata": { - "@id": BLARG.anItemmm, - "title": "an itemmm, yes" + '@id': BLARG.anItemmm, + "sameAs": [ + {"@id": "https://doi.example/13.0/anItemmm"} + ], + 'title': [{'@value': 'an itemmm, yes'}], + "creator": [ + { + "@id": BLARG.aPerson, + "resourceType": [ + {"@id": "Agent"}, + {"@id": "Person"} + ], + "identifier": [ + {"@value": BLARG.aPerson} + ], + "name": [ + {"@value": "a person indeed"} + ] + } + ], + "dateCreated": [ + {"@value": "2001-02-03"} + ], } }, "links": { @@ -243,7 +264,7 @@ class TestJsonapiSearchRenderer(_BaseJsonapiRendererTest, _base.TrovesearchJsonR ], "resourceMetadata": { "@id": BLARG.anItemm, - "title": "an itemm, yes" + "title": [{"@value": "an itemm, yes"}] } }, "links": { diff --git a/tests/trove/render/test_jsonld_renderer.py b/tests/trove/render/test_jsonld_renderer.py index b74d7389c..6161631ea 100644 --- a/tests/trove/render/test_jsonld_renderer.py +++ b/tests/trove/render/test_jsonld_renderer.py @@ -2,7 +2,7 @@ from trove.render.jsonld import RdfJsonldRenderer from trove.render.rendering import SimpleRendering -from ._inputs import BLARG +from trove.vocab.namespaces import BLARG from . import _base @@ -38,7 +38,7 @@ class TestJsonldRenderer(_base.TroveJsonRendererTests): ], "trove:resourceMetadata": { "@id": BLARG.anItem, - "title": "an item, yes" + "title": [{"@value": "an item, yes"}] } }), ), @@ -145,7 +145,7 @@ class TestJsonldSearchRenderer(_base.TrovesearchJsonRendererTests): ], "trove:resourceMetadata": { "@id": BLARG.anItem, - "title": "an item, yes" + "title": [{"@value": "an item, yes"}] } } }, @@ -181,7 +181,7 @@ class TestJsonldSearchRenderer(_base.TrovesearchJsonRendererTests): ], "trove:resourceMetadata": { "@id": BLARG.anItemm, - "title": "an itemm, yes" + "title": [{"@value": "an itemm, yes"}] } } }, @@ -214,8 +214,29 @@ class TestJsonldSearchRenderer(_base.TrovesearchJsonRendererTests): {"@value": BLARG.anItemmm} ], "trove:resourceMetadata": { - "@id": BLARG.anItemmm, - "title": "an itemmm, yes" + '@id': BLARG.anItemmm, + "sameAs": [ + {"@id": "https://doi.example/13.0/anItemmm"} + ], + 'title': [{'@value': 'an itemmm, yes'}], + "creator": [ + { + "@id": BLARG.aPerson, + "resourceType": [ + {"@id": "Agent"}, + {"@id": "Person"} + ], + "identifier": [ + {"@value": BLARG.aPerson} + ], + "name": [ + {"@value": "a person indeed"} + ] + } + ], + "dateCreated": [ + {"@value": "2001-02-03"} + ], } } } diff --git a/tests/trove/render/test_simple_csv_renderer.py b/tests/trove/render/test_simple_csv_renderer.py index d4da76e5b..eb208fa4f 100644 --- a/tests/trove/render/test_simple_csv_renderer.py +++ b/tests/trove/render/test_simple_csv_renderer.py @@ -18,7 +18,7 @@ class TestSimpleCsvRenderer(_base.TrovesearchRendererTests): '@id,sameAs,resourceType,resourceNature,title,name,dateCreated,dateModified,rights\r\n', 'http://blarg.example/vocab/anItem,,,,"an item, yes",,,,\r\n', 'http://blarg.example/vocab/anItemm,,,,"an itemm, yes",,,,\r\n', - 'http://blarg.example/vocab/anItemmm,,,,"an itemmm, yes",,,,\r\n', + 'http://blarg.example/vocab/anItemmm,https://doi.example/13.0/anItemmm,,,"an itemmm, yes",,2001-02-03,,\r\n', )), ), } diff --git a/tests/trove/render/test_simple_json_renderer.py b/tests/trove/render/test_simple_json_renderer.py index cd1d9bcf6..3af79414f 100644 --- a/tests/trove/render/test_simple_json_renderer.py +++ b/tests/trove/render/test_simple_json_renderer.py @@ -27,30 +27,39 @@ class TestSimpleJsonRenderer(_base.TrovesearchJsonRendererTests): "data": [ { "@id": BLARG.anItem, - "title": "an item, yes", - "foaf:isPrimaryTopicOf": [ - { - "@id": BLARG.aCard - } - ] + "title": [{"@value": "an item, yes"}], + "foaf:isPrimaryTopicOf": [{"@id": BLARG.aCard}] }, { "@id": BLARG.anItemm, - "title": "an itemm, yes", - "foaf:isPrimaryTopicOf": [ - { - "@id": BLARG.aCardd - } - ] + "title": [{"@value": "an itemm, yes"}], + "foaf:isPrimaryTopicOf": [{"@id": BLARG.aCardd}] }, { - "@id": BLARG.anItemmm, - "title": "an itemmm, yes", - "foaf:isPrimaryTopicOf": [ + '@id': BLARG.anItemmm, + "sameAs": [ + {"@id": "https://doi.example/13.0/anItemmm"} + ], + 'title': [{'@value': 'an itemmm, yes'}], + "creator": [ { - "@id": BLARG.aCarddd + "@id": BLARG.aPerson, + "resourceType": [ + {"@id": "Agent"}, + {"@id": "Person"} + ], + "identifier": [ + {"@value": BLARG.aPerson} + ], + "name": [ + {"@value": "a person indeed"} + ] } - ] + ], + "dateCreated": [ + {"@value": "2001-02-03"} + ], + "foaf:isPrimaryTopicOf": [{"@id": BLARG.aCarddd}] } ], "links": {}, diff --git a/tests/trove/render/test_simple_tsv_renderer.py b/tests/trove/render/test_simple_tsv_renderer.py index baa3ed5ec..e2874501a 100644 --- a/tests/trove/render/test_simple_tsv_renderer.py +++ b/tests/trove/render/test_simple_tsv_renderer.py @@ -18,7 +18,7 @@ class TestSimpleTsvRenderer(_base.TrovesearchRendererTests): '@id\tsameAs\tresourceType\tresourceNature\ttitle\tname\tdateCreated\tdateModified\trights\r\n', 'http://blarg.example/vocab/anItem\t\t\t\tan item, yes\t\t\t\t\r\n', 'http://blarg.example/vocab/anItemm\t\t\t\tan itemm, yes\t\t\t\t\r\n', - 'http://blarg.example/vocab/anItemmm\t\t\t\tan itemmm, yes\t\t\t\t\r\n', + 'http://blarg.example/vocab/anItemmm\thttps://doi.example/13.0/anItemmm\t\t\tan itemmm, yes\t\t2001-02-03\t\t\r\n', )), ), } diff --git a/tests/trove/render/test_turtle_renderer.py b/tests/trove/render/test_turtle_renderer.py index 306174a44..be17e42f6 100644 --- a/tests/trove/render/test_turtle_renderer.py +++ b/tests/trove/render/test_turtle_renderer.py @@ -30,7 +30,7 @@ class TestTurtleRenderer(_BaseTurtleRendererTest): dcterms:modified "2024-01-01"^^xsd:date ; foaf:primaryTopic blarg:anItem ; trove:focusIdentifier "http://blarg.example/vocab/anItem"^^rdf:string ; - trove:resourceMetadata "{\\"@id\\": \\"http://blarg.example/vocab/anItem\\", \\"title\\": \\"an item, yes\\"}"^^rdf:JSON . + trove:resourceMetadata "{\\"@id\\": \\"http://blarg.example/vocab/anItem\\", \\"title\\": [{\\"@value\\": \\"an item, yes\\"}]}"^^rdf:JSON . ''', ), 'various_types': SimpleRendering( @@ -99,21 +99,21 @@ class TestTurtleTrovesearchRenderer(_BaseTurtleRendererTest, _base.TrovesearchRe dcterms:modified "2024-01-01"^^xsd:date ; foaf:primaryTopic blarg:anItem ; trove:focusIdentifier "http://blarg.example/vocab/anItem"^^rdf:string ; - trove:resourceMetadata "{\\"@id\\": \\"http://blarg.example/vocab/anItem\\", \\"title\\": \\"an item, yes\\"}"^^rdf:JSON . + trove:resourceMetadata "{\\"@id\\": \\"http://blarg.example/vocab/anItem\\", \\"title\\": [{\\"@value\\": \\"an item, yes\\"}]}"^^rdf:JSON . blarg:aCardd a dcat:CatalogRecord, trove:Indexcard ; dcterms:issued "2024-02-02"^^xsd:date ; dcterms:modified "2024-02-02"^^xsd:date ; foaf:primaryTopic blarg:anItemm ; trove:focusIdentifier "http://blarg.example/vocab/anItemm"^^rdf:string ; - trove:resourceMetadata "{\\"@id\\": \\"http://blarg.example/vocab/anItemm\\", \\"title\\": \\"an itemm, yes\\"}"^^rdf:JSON . + trove:resourceMetadata "{\\"@id\\": \\"http://blarg.example/vocab/anItemm\\", \\"title\\": [{\\"@value\\": \\"an itemm, yes\\"}]}"^^rdf:JSON . blarg:aCarddd a dcat:CatalogRecord, trove:Indexcard ; dcterms:issued "2024-03-03"^^xsd:date ; dcterms:modified "2024-03-03"^^xsd:date ; foaf:primaryTopic blarg:anItemmm ; trove:focusIdentifier "http://blarg.example/vocab/anItemmm"^^rdf:string ; - trove:resourceMetadata "{\\"@id\\": \\"http://blarg.example/vocab/anItemmm\\", \\"title\\": \\"an itemmm, yes\\"}"^^rdf:JSON . + trove:resourceMetadata "{\\"@id\\": \\"http://blarg.example/vocab/anItemmm\\", \\"sameAs\\": [{\\"@id\\": \\"https://doi.example/13.0/anItemmm\\"}], \\"title\\": [{\\"@value\\": \\"an itemmm, yes\\"}], \\"creator\\": [{\\"@id\\": \\"http://blarg.example/vocab/aPerson\\", \\"resourceType\\": [{\\"@id\\": \\"Agent\\"}, {\\"@id\\": \\"Person\\"}], \\"identifier\\": [{\\"@value\\": \\"http://blarg.example/vocab/aPerson\\"}], \\"name\\": [{\\"@value\\": \\"a person indeed\\"}]}], \\"dateCreated\\": [{\\"@value\\": \\"2001-02-03\\"}]}"^^rdf:JSON . ''', ), } diff --git a/trove/derive/oaidc_xml.py b/trove/derive/oaidc_xml.py index 610fb49fc..e8d3e0967 100644 --- a/trove/derive/oaidc_xml.py +++ b/trove/derive/oaidc_xml.py @@ -2,8 +2,9 @@ from lxml import etree from primitive_metadata import primitive_rdf as rdf -from share.oaipmh.util import format_datetime, ns, nsmap, SubEl +from share.oaipmh.util import ns, nsmap, SubEl +from trove.util.datetime import datetime_isoformat_z as format_datetime from trove.vocab.namespaces import ( DCTYPE, DCTERMS, diff --git a/trove/links.py b/trove/links.py new file mode 100644 index 000000000..ae8feadeb --- /dev/null +++ b/trove/links.py @@ -0,0 +1,58 @@ +import dataclasses +import urllib.parse + +from django.conf import settings +from django.http import QueryDict +from django.urls import reverse + +from trove.vocab.namespaces import namespaces_shorthand + + +def is_local_url(iri: str) -> bool: + return iri.startswith(settings.SHARE_WEB_URL) + + +def trove_browse_link(iri: str) -> str: + return reverse( + 'trove:browse-iri', + query={ + 'blendCards': True, + 'iri': namespaces_shorthand().compact_iri(iri), + }, + ) + + +@dataclasses.dataclass +class FeedLinks: + rss: str + atom: str + + +def cardsearch_feed_links(cardsearch_iri: str) -> FeedLinks | None: + _split_iri = urllib.parse.urlsplit(cardsearch_iri) + if _split_iri.path != reverse('trove:index-card-search'): + return None + _feed_query = _get_feed_query(_split_iri.query) + _rss_link = urllib.parse.urljoin( + settings.SHARE_WEB_URL, + reverse('trove:cardsearch-rss', query=_feed_query) + ) + _atom_link = urllib.parse.urljoin( + settings.SHARE_WEB_URL, + reverse('trove:cardsearch-atom', query=_feed_query) + ) + return FeedLinks(rss=_rss_link, atom=_atom_link) + + +def _get_feed_query(query_string: str) -> QueryDict: + _qparams = QueryDict(query_string, mutable=True) + for _param_name in list(filter(_irrelevant_feed_param, _qparams.keys())): + del _qparams[_param_name] + return _qparams + + +def _irrelevant_feed_param(query_param_name: str) -> bool: + return ( + query_param_name in ('sort', 'include', 'acceptMediatype', 'blendCards', 'page[cursor]') + or query_param_name.startswith('fields') + ) diff --git a/trove/openapi.py b/trove/openapi.py index 0ed880583..89c0bee67 100644 --- a/trove/openapi.py +++ b/trove/openapi.py @@ -46,7 +46,7 @@ def get_trove_openapi() -> dict[str, Any]: 'contact': { # 'name': # 'url': web-browsable version of this - 'email': 'share-support@osf.io', + 'email': settings.SHARE_SUPPORT_EMAIL, }, # 'license': 'version': get_shtrove_version(), diff --git a/trove/render/__init__.py b/trove/render/__init__.py index 27abfdf79..278697e63 100644 --- a/trove/render/__init__.py +++ b/trove/render/__init__.py @@ -10,6 +10,8 @@ from .simple_csv import TrovesearchSimpleCsvRenderer from .simple_json import TrovesearchSimpleJsonRenderer from .simple_tsv import TrovesearchSimpleTsvRenderer +from .cardsearch_rss import CardsearchRssRenderer +from .cardsearch_atom import CardsearchAtomRenderer __all__ = ('get_renderer_type', 'BaseRenderer') @@ -23,12 +25,16 @@ TrovesearchSimpleJsonRenderer, TrovesearchSimpleTsvRenderer, ) +CARDSEARCH_ONLY_RENDERERS = ( # TODO: use/consider + CardsearchRssRenderer, + CardsearchAtomRenderer, +) RENDERER_BY_MEDIATYPE = { _renderer_type.MEDIATYPE: _renderer_type for _renderer_type in RENDERERS } -DEFAULT_RENDERER_TYPE = RdfJsonapiRenderer # the most stable one +DEFAULT_RENDERER_TYPE = RdfJsonapiRenderer # the most stable one? def get_renderer_type(request: http.HttpRequest) -> type[BaseRenderer]: diff --git a/trove/render/_base.py b/trove/render/_base.py index 9c6ddb5b0..4813115ea 100644 --- a/trove/render/_base.py +++ b/trove/render/_base.py @@ -52,7 +52,7 @@ def response_tripledict(self) -> rdf.RdfTripleDictionary: # TODO: self.response_gathering.ask_all_about or a default ask... return self.response_gathering.leaf_a_record() - def simple_render_document(self) -> str: + def simple_render_document(self) -> str | bytes: raise NotImplementedError def render_document(self) -> ProtoRendering: diff --git a/trove/render/_html.py b/trove/render/_html.py deleted file mode 100644 index 3bded5288..000000000 --- a/trove/render/_html.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations -from collections.abc import Generator -import contextlib -import dataclasses -from xml.etree.ElementTree import ( - Element, - SubElement, - tostring as etree_tostring, -) - -from primitive_metadata import primitive_rdf as rdf - - -__all__ = ('HtmlBuilder',) - -HTML_DOCTYPE = '' - - -@dataclasses.dataclass -class HtmlBuilder: - given_root: Element = dataclasses.field(default_factory=lambda: Element('html')) - _: dataclasses.KW_ONLY - _nested_elements: list[Element] = dataclasses.field(default_factory=list) - _heading_depth: int = 0 - - def __post_init__(self) -> None: - self._nested_elements.append(self.given_root) - - @property - def root_element(self) -> Element: - return self._nested_elements[0] - - @property - def _current_element(self) -> Element: - return self._nested_elements[-1] - - ### - # html-building helper methods - - @contextlib.contextmanager - def deeper_heading(self) -> Generator[str]: - _outer_heading_depth = self._heading_depth - if not _outer_heading_depth: - self._heading_depth = 1 - elif _outer_heading_depth < 6: # h6 deepest - self._heading_depth += 1 - try: - yield f'h{self._heading_depth}' - finally: - self._heading_depth = _outer_heading_depth - - @contextlib.contextmanager - def nest(self, tag_name: str, attrs: dict | None = None) -> Generator[Element]: - _attrs = {**attrs} if attrs else {} - _nested_element = SubElement(self._current_element, tag_name, _attrs) - self._nested_elements.append(_nested_element) - try: - yield self._current_element - finally: - _popped_element = self._nested_elements.pop() - assert _popped_element is _nested_element - - def leaf(self, tag_name: str, *, text: str | None = None, attrs: dict | None = None) -> None: - _leaf_element = SubElement(self._current_element, tag_name, attrs or {}) - if isinstance(text, rdf.Literal): - # TODO: lang - _leaf_element.text = text.unicode_value - elif text is not None: - _leaf_element.text = text - - def as_html_doc(self) -> str: - return '\n'.join(( - HTML_DOCTYPE, - etree_tostring(self.root_element, encoding='unicode', method='html'), - )) diff --git a/trove/render/_simple_trovesearch.py b/trove/render/_simple_trovesearch.py index 657e5b169..5ab316f62 100644 --- a/trove/render/_simple_trovesearch.py +++ b/trove/render/_simple_trovesearch.py @@ -24,19 +24,13 @@ class SimpleTrovesearchRenderer(BaseRenderer): (very entangled with trove/trovesearch/trovesearch_gathering.py) ''' PASSIVE_RENDER = False # knows the properties it cares about - _page_links: set[str] + INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] # assumes osfmap_json + _page_links: set[str] # for use *after* iterating cards/card_pages __already_iterated_cards = False - def simple_unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> str: - raise NotImplementedError - - def simple_multicard_rendering(self, cards: Iterator[tuple[str, JsonObject]]) -> str: - raise NotImplementedError - - def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRendering: - return SimpleRendering( - mediatype=self.MEDIATYPE, - rendered_content=self.simple_unicard_rendering(card_iri, osfmap_json), + def simple_multicard_rendering(self, cards: Iterator[tuple[str, JsonObject]]) -> str | bytes: + raise NotImplementedError( + f'{self.__class__.__name__} must implement either `multicard_rendering` or `simple_multicard_rendering`' ) def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: @@ -46,6 +40,10 @@ def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObjec rendered_content=self.simple_multicard_rendering(_cards), ) + def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRendering: + _page = [(card_iri, osfmap_json)] + return self.multicard_rendering(card_pages=iter([_page])) + def render_document(self) -> ProtoRendering: _focustypes = self.response_focus.type_iris if (TROVE.Cardsearch in _focustypes) or (TROVE.Valuesearch in _focustypes): diff --git a/trove/render/cardsearch_atom.py b/trove/render/cardsearch_atom.py new file mode 100644 index 000000000..3590a446b --- /dev/null +++ b/trove/render/cardsearch_atom.py @@ -0,0 +1,71 @@ +from __future__ import annotations +import typing + +from django.utils.translation import gettext as _ +from primitive_metadata import primitive_rdf as rdf + +from trove.util.datetime import datetime_isoformat_z +from trove.util.json import ( + json_strs, + json_vals, + json_datetimes, +) +from trove.util.xml import XmlBuilder +from trove.vocab import mediatypes +from trove.vocab.trove import trove_indexcard_namespace +from ._simple_trovesearch import SimpleTrovesearchRenderer + +if typing.TYPE_CHECKING: + from collections.abc import Iterator + from trove.util.json import JsonObject + + +class CardsearchAtomRenderer(SimpleTrovesearchRenderer): + '''render card-search results into Atom following https://www.rfc-editor.org/rfc/rfc4287 + ''' + MEDIATYPE = mediatypes.ATOM + + def simple_multicard_rendering(self, cards: Iterator[tuple[str, JsonObject]]) -> bytes: + def _strs(*path: str) -> Iterator[str]: + yield from json_strs(_osfmap_json, path, coerce_str=True) + + def _dates(*path: str) -> Iterator[str]: + yield from map(datetime_isoformat_z, json_datetimes(_osfmap_json, path)) + + _xb = XmlBuilder('feed', {'xmlns': 'http://www.w3.org/2005/Atom'}) + _xb.leaf('title', text=_('shtrove search results')) + _xb.leaf('subtitle', text=_('feed of metadata records matching given filters')) + _xb.leaf('link', text=self.response_focus.single_iri()) + _xb.leaf('id', text=self.response_focus.single_iri()) + for _card_iri, _osfmap_json in cards: + with _xb.nest('entry'): + _iri = _osfmap_json.get('@id', _card_iri) + _xb.leaf('link', {'href': _iri}) + _xb.leaf('id', text=self._atom_id(_card_iri)) + for _title in _strs('title'): + _xb.leaf('title', text=_title) + for _desc in _strs('description'): + _xb.leaf('summary', text=_desc) + for _keyword in _strs('keyword'): + _xb.leaf('category', text=_keyword) + for _created in _dates('dateCreated'): + _xb.leaf('published', text=_created) + for _creator_obj in json_vals(_osfmap_json, 'creator'): + assert isinstance(_creator_obj, dict) + with _xb.nest('author'): + for _name in json_strs(_creator_obj, ['name']): + _xb.leaf('name', text=_name) + _creator_iri = _creator_obj.get('@id') + if _creator_iri: + _xb.leaf('uri', text=_creator_iri) + for _sameas_iri in json_strs(_creator_obj, ['sameAs']): + _xb.leaf('uri', text=_sameas_iri) + return bytes(_xb) + + def _atom_id(self, card_iri: str) -> str: + try: + _uuid = rdf.iri_minus_namespace(card_iri, namespace=trove_indexcard_namespace()) + except ValueError: + return card_iri + else: + return f'urn:uuid:{_uuid}' diff --git a/trove/render/cardsearch_rss.py b/trove/render/cardsearch_rss.py new file mode 100644 index 000000000..89c8a4723 --- /dev/null +++ b/trove/render/cardsearch_rss.py @@ -0,0 +1,61 @@ +from __future__ import annotations +from email.utils import format_datetime as rfc2822_datetime +import typing + +from django.conf import settings +from django.utils.translation import gettext as _ + +from trove.util.json import ( + json_datetimes, + json_vals, + json_strs, +) +from trove.util.xml import XmlBuilder +from trove.vocab import mediatypes +from ._simple_trovesearch import SimpleTrovesearchRenderer + +if typing.TYPE_CHECKING: + from collections.abc import Iterator + from trove.util.json import JsonObject + + +class CardsearchRssRenderer(SimpleTrovesearchRenderer): + '''render card-search results into RSS following https://www.rssboard.org/rss-specification + ''' + MEDIATYPE = mediatypes.RSS + + def simple_multicard_rendering(self, cards: Iterator[tuple[str, JsonObject]]) -> bytes: + def _strs(*path: str) -> Iterator[str]: + yield from json_strs(_osfmap_json, path, coerce_str=True) + + def _dates(*path: str) -> Iterator[str]: + for _dt in json_datetimes(_osfmap_json, path): + yield rfc2822_datetime(_dt) + + _xb = XmlBuilder('rss', {'version': '2.0'}) + with _xb.nest('channel'): + # see https://www.rssboard.org/rss-specification#requiredChannelElements + _xb.leaf('title', text=_('shtrove search results')) + _xb.leaf('link', text=self.response_focus.single_iri()) + _xb.leaf('description', text=_('feed of metadata records matching given filters')) + _xb.leaf('webMaster', text=settings.SHARE_SUPPORT_EMAIL) + for _card_iri, _osfmap_json in cards: + with _xb.nest('item'): + # see https://www.rssboard.org/rss-specification#hrelementsOfLtitemgt + _iri = _osfmap_json.get('@id', _card_iri) + _xb.leaf('link', text=_iri) + _xb.leaf('guid', {'isPermaLink': 'true'}, text=_iri) + for _title in _strs('title'): + _xb.leaf('title', text=_title) + for _desc in _strs('description'): + _xb.leaf('description', text=_desc) + for _keyword in _strs('keyword'): + _xb.leaf('category', text=_keyword) + for _created_date in _dates('dateCreated'): + _xb.leaf('pubDate', text=_created_date) + for _creator_obj in json_vals(_osfmap_json, ['creator']): + assert isinstance(_creator_obj, dict) + _creator_name = next(json_strs(_creator_obj, ['name'])) + _creator_id = _creator_obj.get('@id', _creator_name) + _xb.leaf('author', text=f'{_creator_id} ({_creator_name})') + return bytes(_xb) diff --git a/trove/render/html_browse.py b/trove/render/html_browse.py index dd8f947af..04c308ecf 100644 --- a/trove/render/html_browse.py +++ b/trove/render/html_browse.py @@ -12,7 +12,6 @@ fromstring as etree_fromstring, ) -from django.conf import settings from django.contrib.staticfiles.storage import staticfiles_storage from django.http import QueryDict from django.urls import reverse @@ -20,15 +19,18 @@ import markdown2 from primitive_metadata import primitive_rdf as rdf +from trove.links import ( + trove_browse_link, + is_local_url, +) +from trove.util.html import HtmlBuilder from trove.util.iris import get_sufficiently_unique_iri from trove.util.randomness import shuffled from trove.vocab import mediatypes from trove.vocab import jsonapi from trove.vocab.namespaces import RDF, RDFS, SKOS, DCTERMS, FOAF, DC, OSFMAP from trove.vocab.static_vocab import combined_thesaurus__suffuniq -from trove.vocab.trove import trove_browse_link from ._base import BaseRenderer -from ._html import HtmlBuilder STABLE_MEDIATYPES = (mediatypes.JSONAPI,) UNSTABLE_MEDIATYPES = ( @@ -168,7 +170,7 @@ def __render_subj(self, subj_iri: str, *, include_details: bool = True) -> None: if include_details and (_twopledict := self.__current_data.tripledict.get(subj_iri, {})): _details_attrs = ( {'open': ''} - if (self.__is_focus(subj_iri) or _is_local_url(subj_iri)) + if (self.__is_focus(subj_iri) or is_local_url(subj_iri)) else {} ) with self.__hb.nest('details', _details_attrs): @@ -241,7 +243,7 @@ def __literal( if _is_markdown: # TODO: tests for safe_mode _html = markdown2.markdown(_lit.unicode_value, safe_mode='escape') - self.__hb._current_element.append(etree_fromstring(f'{_html}')) + self.__hb.current_element.append(etree_fromstring(f'{_html}')) else: self.__hb.leaf('q', text=_lit) @@ -331,7 +333,7 @@ def __iri_link_and_labels(self, iri: str) -> None: def __nest_link(self, iri: str, attrs: dict[str, str] | None = None) -> contextlib.AbstractContextManager[Element]: _href = ( iri - if _is_local_url(iri) + if is_local_url(iri) else trove_browse_link(iri) ) return self.__hb.nest('a', attrs={**(attrs or {}), 'href': _href}) @@ -381,7 +383,7 @@ def _hue_turn_css(self) -> Generator[str]: def _queryparam_href(self, param_name: str, param_value: str | None) -> str: _base_url = self.response_focus.single_iri() - if not _is_local_url(_base_url): + if not is_local_url(_base_url): _base_url = trove_browse_link(_base_url) (_scheme, _netloc, _path, _query, _fragment) = urlsplit(_base_url) _qparams = QueryDict(_query, mutable=True) @@ -415,7 +417,7 @@ def __iri_display_lines(self, iri: str) -> Generator[str]: else: (_scheme, _netloc, _path, _query, _fragment) = urlsplit(iri) # first line with path - if _is_local_url(iri): + if is_local_url(iri): yield f'/{_path.lstrip('/')}' elif _netloc: yield f'://{_netloc}{_path}' @@ -438,10 +440,6 @@ def _append_class(el: Element, element_class: str) -> None: ) -def _is_local_url(iri: str) -> bool: - return iri.startswith(settings.SHARE_WEB_URL) - - def _is_sequence_obj(obj: rdf.RdfObject) -> bool: return ( isinstance(obj, frozenset) diff --git a/trove/render/jsonapi.py b/trove/render/jsonapi.py index 536e562bc..73bb21c61 100644 --- a/trove/render/jsonapi.py +++ b/trove/render/jsonapi.py @@ -7,13 +7,17 @@ import itertools import json import time -from typing import Iterable, Union, List, Any, Dict, Tuple, Iterator +from typing import Iterable, Union, Any, Iterator -from typing import Optional from primitive_metadata import primitive_rdf from trove import exceptions as trove_exceptions +from trove.util.json import ( + JsonObject, + JsonValue, +) from trove.vocab.jsonapi import ( + JSONAPI_LINK, JSONAPI_MEMBERNAME, JSONAPI_RELATIONSHIP, JSONAPI_ATTRIBUTE, @@ -86,9 +90,9 @@ def simple_render_document(self) -> str: indent=2, # TODO: pretty-print query param? ) - def render_dict(self, primary_iris: Union[str, Iterable[str]]) -> dict[str, Any]: - _primary_data: dict | list | None = None - _included_data = [] + def render_dict(self, primary_iris: Union[str, Iterable[str]]) -> JsonObject: + _primary_data: JsonValue = None + _included_data: list[JsonValue] = [] with self._contained__to_include() as _to_include: if isinstance(primary_iris, str): _already_included = {primary_iris} @@ -104,26 +108,37 @@ def render_dict(self, primary_iris: Union[str, Iterable[str]]) -> dict[str, Any] if _next not in _already_included: _already_included.add(_next) _included_data.append(self.render_resource_object(_next)) - _document = {'data': _primary_data} + _document: JsonObject = {'data': _primary_data} if _included_data: _document['included'] = _included_data return _document - def render_resource_object(self, iri_or_blanknode: _IriOrBlanknode) -> dict[str, Any]: - _resource_object = {**self.render_identifier_object(iri_or_blanknode)} + def render_resource_object(self, iri_or_blanknode: _IriOrBlanknode) -> JsonObject: + _resource_object: JsonObject = {**self.render_identifier_object(iri_or_blanknode)} _twopledict = ( (self.response_data.tripledict.get(iri_or_blanknode) or {}) if isinstance(iri_or_blanknode, str) else primitive_rdf.twopledict_from_twopleset(iri_or_blanknode) ) + _links: JsonObject = {} for _pred, _obj_set in _twopledict.items(): - if _pred != RDF.type: - self._render_field(_pred, _obj_set, into=_resource_object) + if _pred == JSONAPI_LINK: + _links.update( + self._render_link_object(_link_obj) + for _link_obj in _obj_set + ) + elif _pred != RDF.type: + _doc_key, _field_key, _field_value = self._render_field(_pred, _obj_set) + _doc_obj = _resource_object.setdefault(_doc_key, {}) + assert isinstance(_doc_obj, dict) + _doc_obj[_field_key] = _field_value if isinstance(iri_or_blanknode, str): - _resource_object.setdefault('links', {})['self'] = iri_or_blanknode + _links['self'] = iri_or_blanknode + if _links: + _resource_object['links'] = _links return _resource_object - def render_identifier_object(self, iri_or_blanknode: _IriOrBlanknode) -> Any | dict[str, Any]: + def render_identifier_object(self, iri_or_blanknode: _IriOrBlanknode) -> JsonObject: try: return self._identifier_object_cache[iri_or_blanknode] except KeyError: @@ -152,7 +167,7 @@ def render_identifier_object(self, iri_or_blanknode: _IriOrBlanknode) -> Any | d self._identifier_object_cache[iri_or_blanknode] = _id_obj return _id_obj - def _single_typename(self, type_iris: list[str]) -> Optional[str]: + def _single_typename(self, type_iris: list[str]) -> str: if not type_iris: return '' if len(type_iris) == 1: @@ -164,7 +179,7 @@ def _single_typename(self, type_iris: list[str]) -> Optional[str]: return self._membername_for_iri(_type_iris[0]) return self._membername_for_iri(sorted(type_iris)[0]) - def _membername_for_iri(self, iri: str) -> Optional[str] | Any: + def _membername_for_iri(self, iri: str) -> str: try: _membername = next(self.thesaurus.q(iri, JSONAPI_MEMBERNAME)) except StopIteration: @@ -189,12 +204,12 @@ def _resource_id_for_iri(self, iri: str) -> Any: # as fallback, encode the iri into a valid jsonapi member name return base64.urlsafe_b64encode(iri.encode()).decode() - def _render_field(self, predicate_iri: str, object_set: Iterable[Any], *, into: dict[str, Any]) -> None: + def _render_field(self, predicate_iri: str, object_set: Iterable[Any]) -> tuple[str, str, JsonValue]: _is_relationship = (predicate_iri, RDF.type, JSONAPI_RELATIONSHIP) in self.thesaurus _is_attribute = (predicate_iri, RDF.type, JSONAPI_ATTRIBUTE) in self.thesaurus _field_key = self._membername_for_iri(predicate_iri) _doc_key = 'meta' # unless configured for jsonapi, default to unstructured 'meta' - if ':' not in _field_key: # type: ignore + if ':' not in _field_key: if _is_relationship: _doc_key = 'relationships' elif _is_attribute: @@ -203,10 +218,9 @@ def _render_field(self, predicate_iri: str, object_set: Iterable[Any], *, into: _fieldvalue = self._render_relationship_object(predicate_iri, object_set) else: _fieldvalue = self._one_or_many(predicate_iri, self._attribute_datalist(object_set)) # type: ignore - # update the given `into` resource object - into.setdefault(_doc_key, {})[_field_key] = _fieldvalue + return _doc_key, _field_key, _fieldvalue - def _one_or_many(self, predicate_iri: str, datalist: list[Any]) -> Union[list[Any], Any, None]: + def _one_or_many(self, predicate_iri: str, datalist: list[Any]) -> JsonValue: _only_one = (predicate_iri, RDF.type, OWL.FunctionalProperty) in self.thesaurus if _only_one: if len(datalist) > 1: @@ -214,19 +228,19 @@ def _one_or_many(self, predicate_iri: str, datalist: list[Any]) -> Union[list[An return datalist[0] if datalist else None return datalist - def _attribute_datalist(self, object_set: Iterable[Any]) -> List[Any]: + def _attribute_datalist(self, object_set: Iterable[Any]) -> list[Any]: return [ self._render_attribute_datum(_obj) for _obj in object_set ] def _render_relationship_object( - self, - predicate_iri: str, - object_set: Iterable[Union[frozenset[Any], str]] - ) -> Dict[str, Any]: + self, + predicate_iri: str, + object_set: Iterable[Union[frozenset[Any], str]] + ) -> JsonObject: _data = [] - _links = {} + _links: JsonObject = {} for _obj in object_set: if isinstance(_obj, frozenset): if (RDF.type, RDF.Seq) in _obj: @@ -243,14 +257,14 @@ def _render_relationship_object( assert isinstance(_obj, str) _data.append(self.render_identifier_object(_obj)) self._pls_include(_obj) - _relationship_obj = { + _relationship_obj: JsonObject = { 'data': self._one_or_many(predicate_iri, _data), } if _links: _relationship_obj['links'] = _links return _relationship_obj - def _render_link_object(self, link_obj: frozenset[Tuple[Any, Any]]) -> Tuple[str, Dict[str, Any]]: + def _render_link_object(self, link_obj: frozenset[tuple[Any, Any]]) -> tuple[str, JsonObject]: _membername = next( _obj.unicode_value for _pred, _obj in link_obj @@ -292,14 +306,14 @@ def _pls_include(self, item: Any) -> None: if self.__to_include is not None: self.__to_include.add(item) - def _render_attribute_datum(self, rdfobject: primitive_rdf.RdfObject) -> dict[Any, Any] | list[Any] | str | float | int: + def _render_attribute_datum(self, rdfobject: primitive_rdf.RdfObject) -> JsonValue: if isinstance(rdfobject, frozenset): if (RDF.type, RDF.Seq) in rdfobject: return [ self._render_attribute_datum(_seq_obj) for _seq_obj in primitive_rdf.sequence_objects_in_order(rdfobject) ] - _json_blanknode = {} + _json_blanknode: JsonObject = {} for _pred, _obj_set in primitive_rdf.twopledict_from_twopleset(rdfobject).items(): _key = self._membername_for_iri(_pred) _json_blanknode[_key] = self._one_or_many(_pred, self._attribute_datalist(_obj_set)) diff --git a/trove/render/rendering/html_wrapped.py b/trove/render/rendering/html_wrapped.py index 360e09446..4aadaff58 100644 --- a/trove/render/rendering/html_wrapped.py +++ b/trove/render/rendering/html_wrapped.py @@ -3,7 +3,7 @@ from typing import Iterator from trove.vocab import mediatypes -from trove.render._html import HTML_DOCTYPE +from trove.util.html import HTML_DOCTYPE from .proto import ProtoRendering @@ -16,5 +16,7 @@ def iter_content(self) -> Iterator[str]: yield HTML_DOCTYPE yield '
'
         for _content in self.inner_rendering.iter_content():
+            if not isinstance(_content, str):
+                _content = _content.decode()
             yield html.escape(_content)
         yield '
' diff --git a/trove/render/rendering/proto.py b/trove/render/rendering/proto.py index ac0269f94..955940acb 100644 --- a/trove/render/rendering/proto.py +++ b/trove/render/rendering/proto.py @@ -11,6 +11,6 @@ class ProtoRendering(Protocol): ''' mediatype: str # required attribute - def iter_content(self) -> Iterator[str]: + def iter_content(self) -> Iterator[str] | Iterator[bytes]: '''`iter_content`: (only) required method ''' diff --git a/trove/render/rendering/simple.py b/trove/render/rendering/simple.py index 2300ababf..f99f8b879 100644 --- a/trove/render/rendering/simple.py +++ b/trove/render/rendering/simple.py @@ -11,7 +11,7 @@ class SimpleRendering(ProtoRendering): '''for simple pre-rendered string content ''' mediatype: str - rendered_content: str = '' + rendered_content: str | bytes = '' - def iter_content(self) -> Generator[str]: + def iter_content(self) -> Generator[str] | Generator[bytes]: yield self.rendered_content diff --git a/trove/render/rendering/streamable.py b/trove/render/rendering/streamable.py index 4570a66be..5b9ad2ee6 100644 --- a/trove/render/rendering/streamable.py +++ b/trove/render/rendering/streamable.py @@ -8,10 +8,10 @@ @dataclasses.dataclass class StreamableRendering(ProtoRendering): mediatype: str - content_stream: Iterator[str] = iter(()) + content_stream: Iterator[str] | Iterator[bytes] = iter(()) _started_already: bool = False - def iter_content(self) -> Iterator[str]: + def iter_content(self) -> Iterator[str] | Iterator[bytes]: if self._started_already: raise trove_exceptions.CannotRenderStreamTwice self._started_already = True diff --git a/trove/render/simple_csv.py b/trove/render/simple_csv.py index a67935335..cd14c348e 100644 --- a/trove/render/simple_csv.py +++ b/trove/render/simple_csv.py @@ -16,20 +16,22 @@ ValuesearchParams, ) from trove.util.iter import iter_unique +from trove.util.json import json_prims from trove.util.propertypath import Propertypath, GLOB_PATHSTEP from trove.vocab import mediatypes from trove.vocab import osfmap -from trove.vocab.namespaces import TROVE from ._simple_trovesearch import SimpleTrovesearchRenderer from .rendering import ProtoRendering from .rendering.streamable import StreamableRendering if TYPE_CHECKING: from trove.util.trove_params import BasicTroveParams - from trove.util.json import JsonValue, JsonObject + from trove.util.json import ( + JsonObject, + JsonPath, + ) _logger = logging.getLogger(__name__) -type Jsonpath = Sequence[str] # path of json keys type CsvValue = str | int | float | None _MULTIVALUE_DELIMITER = ' ; ' # possible improvement: smarter in-value delimiting? @@ -39,13 +41,8 @@ class TrovesearchSimpleCsvRenderer(SimpleTrovesearchRenderer): MEDIATYPE = mediatypes.CSV - INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] CSV_DIALECT: ClassVar[type[csv.Dialect]] = csv.excel - def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRendering: - _page = [(card_iri, osfmap_json)] - return self.multicard_rendering(card_pages=iter([_page])) - def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: _doc = TabularDoc( card_pages, @@ -75,7 +72,7 @@ class TabularDoc: _started: bool = False @functools.cached_property - def column_jsonpaths(self) -> tuple[Jsonpath, ...]: + def column_jsonpaths(self) -> tuple[JsonPath, ...]: _column_jsonpaths = ( _osfmap_jsonpath(_path) for _path in self._column_paths() @@ -120,10 +117,11 @@ def _row_values(self, osfmap_json: JsonObject) -> list[CsvValue]: for _field_path in self.column_jsonpaths ] - def _row_field_value(self, osfmap_json: JsonObject, field_path: Jsonpath) -> CsvValue: + def _row_field_value(self, osfmap_json: JsonObject, field_path: JsonPath) -> CsvValue: _rendered_values = [ - _render_tabularly(_obj) - for _obj in _iter_values(osfmap_json, field_path) + _obj + for _obj in json_prims(osfmap_json, field_path, _VALUE_KEY_PREFERENCE) + if _obj is not None ] if len(_rendered_values) == 1: return _rendered_values[0] # preserve type for single numbers @@ -131,7 +129,7 @@ def _row_field_value(self, osfmap_json: JsonObject, field_path: Jsonpath) -> Csv return _MULTIVALUE_DELIMITER.join(map(str, _rendered_values)) -def _osfmap_jsonpath(iri_path: Propertypath) -> Jsonpath: +def _osfmap_jsonpath(iri_path: Propertypath) -> JsonPath: _shorthand = osfmap.osfmap_json_shorthand() return tuple( _shorthand.compact_iri(_pathstep) @@ -139,50 +137,6 @@ def _osfmap_jsonpath(iri_path: Propertypath) -> Jsonpath: ) -def _has_value(osfmap_json: JsonObject, path: Jsonpath) -> bool: - try: - next(_iter_values(osfmap_json, path)) - except StopIteration: - return False - else: - return True - - -def _iter_values(osfmap_json: JsonObject, path: Jsonpath) -> Generator[JsonValue]: - assert path - (_step, *_rest) = path - _val = osfmap_json.get(_step) - if _rest: - if isinstance(_val, dict): - yield from _iter_values(_val, _rest) - elif isinstance(_val, list): - for _val_obj in _val: - if isinstance(_val_obj, dict): - yield from _iter_values(_val_obj, _rest) - else: - if isinstance(_val, list): - yield from _val - elif _val is not None: - yield _val - - -def _render_tabularly(json_val: JsonValue) -> CsvValue: - if isinstance(json_val, (str, int, float)): - return json_val - if isinstance(json_val, dict): - for _key in _VALUE_KEY_PREFERENCE: - _val = json_val.get(_key) - if isinstance(_val, list): - return ( - _render_tabularly(_val[0]) - if _val - else None - ) - if _val is not None: - return _render_tabularly(_val) - return None - - class _Echo: '''a write-only file-like object, to convince `csv.csvwriter.writerow` to return strings diff --git a/trove/render/simple_json.py b/trove/render/simple_json.py index a29025d37..099184382 100644 --- a/trove/render/simple_json.py +++ b/trove/render/simple_json.py @@ -11,7 +11,10 @@ ) from trove.vocab import mediatypes from trove.vocab.namespaces import TROVE, RDF -from .rendering import ProtoRendering +from .rendering import ( + ProtoRendering, + SimpleRendering, +) from .rendering.streamable import StreamableRendering from ._simple_trovesearch import SimpleTrovesearchRenderer if typing.TYPE_CHECKING: @@ -27,14 +30,16 @@ class TrovesearchSimpleJsonRenderer(SimpleTrovesearchRenderer): '''for "simple json" search api -- very entangled with trove/trovesearch/trovesearch_gathering.py ''' MEDIATYPE = mediatypes.JSON - INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] - def simple_unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> str: - return json.dumps({ - 'data': self._render_card_content(card_iri, osfmap_json), - 'links': self._render_links(), - 'meta': self._render_meta(), - }, indent=2) + def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRendering: + return SimpleRendering( + mediatype=self.MEDIATYPE, + rendered_content=json.dumps({ + 'data': self._render_card_content(card_iri, osfmap_json), + 'links': self._render_links(), + 'meta': self._render_meta(), + }, indent=2), + ) def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: return StreamableRendering( diff --git a/trove/trovesearch/page_cursor.py b/trove/trovesearch/page_cursor.py index 5bbdf5ac0..4f52dd40a 100644 --- a/trove/trovesearch/page_cursor.py +++ b/trove/trovesearch/page_cursor.py @@ -17,7 +17,6 @@ DEFAULT_PAGE_SIZE = 13 MAX_PAGE_SIZE = 101 -UNBOUNDED_PAGE_SIZE = math.inf # json-serialized as "Infinity" @dataclasses.dataclass diff --git a/trove/trovesearch/search_handle.py b/trove/trovesearch/search_handle.py index b3ce4a8f7..ec3fb74ce 100644 --- a/trove/trovesearch/search_handle.py +++ b/trove/trovesearch/search_handle.py @@ -39,7 +39,8 @@ class CardsearchHandle(BasicSearchHandle): search_result_page: typing.Iterable[CardsearchResult] = () related_propertypath_results: list[PropertypathUsage] = dataclasses.field(default_factory=list) - def __post_init__(self): # type: ignore + def __post_init__(self) -> None: + # update cursor and/or search_result_page to agree with each other _cursor = self.cursor _page = self.search_result_page if ( # TODO: move this logic into the... cursor? @@ -60,7 +61,6 @@ def __post_init__(self): # type: ignore elif not _cursor.has_many_more(): # visiting first page for the first time _cursor.first_page_ids = [_result.card_id for _result in _page] - return _page def get_next_streaming_handle(self) -> typing.Self | None: if self.cursor.is_complete_page: diff --git a/trove/trovesearch/search_params.py b/trove/trovesearch/search_params.py index dfe047a49..5149ba941 100644 --- a/trove/trovesearch/search_params.py +++ b/trove/trovesearch/search_params.py @@ -35,6 +35,7 @@ get_single_value, ) from trove.vocab import osfmap +from trove.vocab.jsonapi import JSONAPI_LINK from trove.vocab.trove import trove_json_shorthand from trove.vocab.namespaces import RDF, TROVE, OWL, FOAF, DCTERMS if typing.TYPE_CHECKING: @@ -82,6 +83,7 @@ (TROVE.totalResultCount,), (TROVE.cardSearchText,), (TROVE.cardSearchFilter,), + (JSONAPI_LINK,), ], TROVE.Valuesearch: [ (TROVE.propertyPath,), diff --git a/trove/trovesearch/trovesearch_gathering.py b/trove/trovesearch/trovesearch_gathering.py index f10006920..beb703e68 100644 --- a/trove/trovesearch/trovesearch_gathering.py +++ b/trove/trovesearch/trovesearch_gathering.py @@ -9,9 +9,11 @@ from trove import models as trove_db from trove.derive.osfmap_json import _RdfOsfmapJsonldRenderer +from trove.links import cardsearch_feed_links from trove.util.iris import get_sufficiently_unique_iri from trove.vocab.namespaces import RDF, FOAF, DCTERMS, RDFS, DCAT, TROVE from trove.vocab.jsonapi import ( + JSONAPI_LINK, JSONAPI_LINK_OBJECT, JSONAPI_MEMBERNAME, ) @@ -313,6 +315,17 @@ def gather_valuesearch_count(focus: ValuesearchFocus, **kwargs: Any) -> Gatherer yield (TROVE.totalResultCount, focus.search_handle.total_result_count) +@trovesearch_by_indexstrategy.gatherer( + JSONAPI_LINK, + focustype_iris={TROVE.Cardsearch}, +) +def gather_feed_links(focus: CardsearchFocus, **kwargs: Any) -> GathererGenerator: + _feed_links = cardsearch_feed_links(focus.single_iri()) + if _feed_links is not None: + yield (JSONAPI_LINK, _jsonapi_link('rss', _feed_links.rss)) + yield (JSONAPI_LINK, _jsonapi_link('atom', _feed_links.atom)) + + # @trovesearch_by_indexstrategy.gatherer( # focustype_iris={TROVE.Indexcard}, # ) diff --git a/trove/urls.py b/trove/urls.py index 64f4b4e3c..cb729facd 100644 --- a/trove/urls.py +++ b/trove/urls.py @@ -1,16 +1,20 @@ from django.urls import path, re_path from .views.browse import BrowseIriView +from .views.docs import ( + OpenapiHtmlView, + OpenapiJsonView, +) +from .views.feeds import ( + CardsearchRssView, + CardsearchAtomView, +) from .views.ingest import RdfIngestView from .views.indexcard import IndexcardView from .views.search import ( CardsearchView, ValuesearchView, ) -from .views.docs import ( - OpenapiHtmlView, - OpenapiJsonView, -) app_name = 'trove' @@ -19,6 +23,8 @@ path('index-card/', view=IndexcardView.as_view(), name='index-card'), path('index-card-search', view=CardsearchView.as_view(), name='index-card-search'), path('index-value-search', view=ValuesearchView.as_view(), name='index-value-search'), + path('index-card-search/rss.xml', view=CardsearchRssView.as_view(), name='cardsearch-rss'), + path('index-card-search/atom.xml', view=CardsearchAtomView.as_view(), name='cardsearch-atom'), path('browse', view=BrowseIriView.as_view(), name='browse-iri'), path('ingest', view=RdfIngestView.as_view(), name='ingest-rdf'), path('docs/openapi.json', view=OpenapiJsonView.as_view(), name='docs.openapi-json'), diff --git a/trove/util/datetime.py b/trove/util/datetime.py new file mode 100644 index 000000000..ce437e79c --- /dev/null +++ b/trove/util/datetime.py @@ -0,0 +1,18 @@ +import datetime + +from primitive_metadata import primitive_rdf as rdf + + +def datetime_isoformat_z(dt: datetime.datetime | rdf.Literal | str) -> str: + """format (or reformat) a datetime in UTC with 'Z' timezone indicator + + for complying with standards that require the 'Z', like OAI-PMH + https://www.openarchives.org/OAI/openarchivesprotocol.html#Dates + """ + if isinstance(dt, rdf.Literal): + dt = dt.unicode_value + if isinstance(dt, str): + dt = datetime.datetime.fromisoformat(dt) + if isinstance(dt, datetime.datetime) and dt.tzinfo is None: + dt = dt.astimezone(datetime.UTC) + return dt.strftime('%Y-%m-%dT%H:%M:%SZ') diff --git a/trove/util/html.py b/trove/util/html.py new file mode 100644 index 000000000..1cef3bb5e --- /dev/null +++ b/trove/util/html.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from collections.abc import Generator +import contextlib +import dataclasses +from xml.etree.ElementTree import tostring as etree_tostring + +from trove.util.xml import XmlBuilder + + +__all__ = ('HtmlBuilder',) + +HTML_DOCTYPE = '' + + +@dataclasses.dataclass +class HtmlBuilder(XmlBuilder): + root_tag_name: str = 'html' + _: dataclasses.KW_ONLY + _heading_depth: int = 0 + + ### + # html-building helper methods + + @contextlib.contextmanager + def deeper_heading(self) -> Generator[str]: + _outer_heading_depth = self._heading_depth + if not _outer_heading_depth: + self._heading_depth = 1 + elif _outer_heading_depth < 6: # h6 deepest + self._heading_depth += 1 + try: + yield f'h{self._heading_depth}' + finally: + self._heading_depth = _outer_heading_depth + + def as_html_doc(self) -> str: + return '\n'.join((HTML_DOCTYPE, str(self))) + + def __str__(self) -> str: + return etree_tostring(self.root_element, encoding='unicode', method='html') + + def __bytes__(self) -> bytes: + return etree_tostring(self.root_element, encoding='utf-8', method='html') diff --git a/trove/util/json.py b/trove/util/json.py index aa647681c..496a0607a 100644 --- a/trove/util/json.py +++ b/trove/util/json.py @@ -1,6 +1,99 @@ from __future__ import annotations +from collections.abc import ( + Iterable, + Sequence, + Generator, +) +import datetime +### +# types for json-serializable stuff + +JsonPrimitive = str | int | float | bool | None + +type JsonValue = JsonPrimitive | list[JsonValue] | JsonObject + +type JsonNonArrayValue = JsonPrimitive | JsonObject + type JsonObject = dict[str, JsonValue] -type JsonValue = str | int | float | list[JsonValue] | JsonObject | None +type JsonPath = Sequence[str] # path of json keys + +JSONLD_VALUE_KEYS = ('@value', '@id') + +### +# utils for navigating nested json in the style of trove.derive.osfmap_json +# (TODO: more general json-ld utils) + + +def json_vals(json_obj: JsonObject, path: JsonPath) -> Generator[JsonValue]: + assert path + (_step, *_rest) = path + try: + _val = json_obj[_step] + except KeyError: + return + if _rest: + if isinstance(_val, dict): + yield from json_vals(_val, _rest) + elif isinstance(_val, list): + for _val_obj in _val: + if isinstance(_val_obj, dict): + yield from json_vals(_val_obj, _rest) + else: + if isinstance(_val, list): + yield from _val + else: + yield _val + + +def json_prims( + json_val: JsonValue, + path: JsonPath, + value_key_options: Iterable[str] = JSONLD_VALUE_KEYS, +) -> Generator[JsonPrimitive]: + if isinstance(json_val, list): + for _list_val in json_val: + yield from json_prims(_list_val, path, value_key_options) + elif path: + if isinstance(json_val, dict): + for _path_val in json_vals(json_val, path): + yield from json_prims(_path_val, (), value_key_options) + else: # no path; not list + if isinstance(json_val, JsonPrimitive): + yield json_val + elif isinstance(json_val, dict): + try: + yield next( + _val + for _key in value_key_options + if _key in json_val and isinstance(_val := json_val[_key], JsonPrimitive) + ) + except StopIteration: + pass + + +def json_strs( + json_val: JsonValue, + path: JsonPath, + value_key_options: Iterable[str] = JSONLD_VALUE_KEYS, + coerce_str: bool = False, +) -> Generator[str]: + for _prim in json_prims(json_val, path, value_key_options): + if isinstance(_prim, str): + yield _prim + elif coerce_str and (_prim is not None): + yield str(_prim) + + +def json_datetimes( + json_val: JsonValue, + path: JsonPath, +) -> Generator[datetime.datetime]: + for _prim in json_prims(json_val, path): + if isinstance(_prim, str): + try: + yield datetime.datetime.fromisoformat(_prim) + except ValueError: + pass diff --git a/trove/util/xml.py b/trove/util/xml.py new file mode 100644 index 000000000..79ca0f972 --- /dev/null +++ b/trove/util/xml.py @@ -0,0 +1,66 @@ +from __future__ import annotations +from collections.abc import Generator +import contextlib +import dataclasses +from xml.etree.ElementTree import ( + Element, + SubElement, + tostring as etree_tostring, +) + +from primitive_metadata import primitive_rdf as rdf + + +__all__ = ('XmlBuilder',) + + +@dataclasses.dataclass +class XmlBuilder: + '''XmlBuilder: for building XML (an alternate convenience wrapper around xml.etree) + + >>> _xb = XmlBuilder('foo') + >>> with _xb.nest('bar', {'blib': 'bloz'}): + ... _xb.leaf('baz', text='hello') + ... _xb.leaf('boz', {'blib': 'blab'}, text='world') + >>> str(_xb) + ''' + root_tag_name: str + root_attrs: dict = dataclasses.field(default_factory=dict) + _: dataclasses.KW_ONLY + _nested_elements: list[Element] = dataclasses.field(repr=False, init=False) + + def __post_init__(self) -> None: + self._nested_elements = [Element(self.root_tag_name, self.root_attrs)] + + @property + def root_element(self) -> Element: + return self._nested_elements[0] + + @property + def current_element(self) -> Element: + return self._nested_elements[-1] + + @contextlib.contextmanager + def nest(self, tag_name: str, attrs: dict | None = None) -> Generator[Element]: + _attrs = {**attrs} if attrs else {} + _nested_element = SubElement(self.current_element, tag_name, _attrs) + self._nested_elements.append(_nested_element) + try: + yield self.current_element + finally: + _popped_element = self._nested_elements.pop() + assert _popped_element is _nested_element + + def leaf(self, tag_name: str, attrs: dict | None = None, *, text: str | rdf.Literal | None = None) -> None: + _leaf_element = SubElement(self.current_element, tag_name, attrs or {}) + if isinstance(text, rdf.Literal): + # TODO: lang + _leaf_element.text = text.unicode_value + elif text is not None: + _leaf_element.text = text + + def __str__(self) -> str: + return etree_tostring(self.root_element, encoding='unicode') + + def __bytes__(self) -> bytes: + return etree_tostring(self.root_element, encoding='utf-8', xml_declaration=True) diff --git a/trove/views/_base.py b/trove/views/_base.py index cd2a0fcbd..feede764b 100644 --- a/trove/views/_base.py +++ b/trove/views/_base.py @@ -45,7 +45,7 @@ def _render_response_content(self, request, params, renderer_type: type[BaseRend def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse | StreamingHttpResponse: try: - _renderer_type = get_renderer_type(request) + _renderer_type = self._get_renderer_type(request) except trove_exceptions.CannotRenderMediatype as _error: return make_http_error_response( error=_error, @@ -63,6 +63,9 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse | StreamingHt renderer_type=_renderer_type, ) + def _get_renderer_type(self, request: HttpRequest): + return get_renderer_type(request) + def _parse_params(self, request: HttpRequest): return self.params_type.from_querystring(request.META['QUERY_STRING']) @@ -74,6 +77,8 @@ class GatheredTroveView(BaseTroveView, abc.ABC): focus_type_iris: ClassVar[Container[str]] = () def _render_response_content(self, request, params, renderer_type: type[BaseRenderer], url_kwargs): + '''implement abstract method from BaseTroveView + ''' _focus = self._build_focus(request, params, url_kwargs) _renderer = self._gather_to_renderer(_focus, params, renderer_type) return _renderer.render_document() @@ -123,6 +128,8 @@ def cached_static_triples(cls, focus_iri): return cls.get_static_triples(focus_iri) def _render_response_content(self, request, params, renderer_type: type[BaseRenderer], url_kwargs): + '''implement abstract method from BaseTroveView + ''' _focus_iri = self.get_focus_iri() _triples = self.cached_static_triples(_focus_iri) _focus = gather.Focus.new( diff --git a/trove/views/_responder.py b/trove/views/_responder.py index a0599e0f8..cada5e74d 100644 --- a/trove/views/_responder.py +++ b/trove/views/_responder.py @@ -17,6 +17,8 @@ mediatypes.JSON, mediatypes.JSONLD, mediatypes.JSONAPI, + mediatypes.ATOM, + mediatypes.RSS, } diff --git a/trove/views/feeds.py b/trove/views/feeds.py new file mode 100644 index 000000000..ae4b90eb8 --- /dev/null +++ b/trove/views/feeds.py @@ -0,0 +1,48 @@ +from __future__ import annotations +import dataclasses +from typing import TYPE_CHECKING + +from trove.render.cardsearch_rss import CardsearchRssRenderer +from trove.render.cardsearch_atom import CardsearchAtomRenderer +from trove.trovesearch.search_params import ( + CardsearchParams, + SortParam, + ValueType, +) +from trove.views.search import CardsearchView +from trove.vocab.namespaces import DCTERMS + +if TYPE_CHECKING: + from django.http import HttpRequest + + +class CardsearchRssView(CardsearchView): + def _get_renderer_type(self, request: HttpRequest): + '''override method from BaseTroveView + + ignore requested mediatype; always render RSS + ''' + return CardsearchRssRenderer + + def _parse_params(self, request: HttpRequest): + '''override method from BaseTroveView + + ignore requested sort; always sort by date created, descending + ''' + _params: CardsearchParams = super()._parse_params(request) + return dataclasses.replace(_params, sort_list=( + SortParam( + value_type=ValueType.DATE, + propertypath=(DCTERMS.created,), + descending=True, + ), + )) + + +class CardsearchAtomView(CardsearchRssView): + def _get_renderer_type(self, request: HttpRequest): + '''override method from BaseTroveView + + ignore requested mediatype; always render Atom + ''' + return CardsearchAtomRenderer diff --git a/trove/vocab/mediatypes.py b/trove/vocab/mediatypes.py index 71a1990f4..24dad5053 100644 --- a/trove/vocab/mediatypes.py +++ b/trove/vocab/mediatypes.py @@ -5,6 +5,8 @@ HTML = 'text/html' TSV = 'text/tab-separated-values' CSV = 'text/csv' +RSS = 'application/rss+xml' +ATOM = 'application/atom+xml' _file_extensions = { @@ -15,6 +17,8 @@ HTML: '.html', TSV: '.tsv', CSV: '.csv', + RSS: '.xml', + ATOM: '.xml', } _PARAMETER_DELIMITER = ';' diff --git a/trove/vocab/trove.py b/trove/vocab/trove.py index ac7ac7a51..5649db6b8 100644 --- a/trove/vocab/trove.py +++ b/trove/vocab/trove.py @@ -1,10 +1,8 @@ import functools -import urllib.parse from typing import Union, Any from uuid import UUID from django.conf import settings -from django.urls import reverse from primitive_metadata.primitive_rdf import ( IriNamespace, IriShorthand, @@ -44,14 +42,6 @@ def _literal_markdown(text: str, *, language: str) -> literal: return literal(text, language=language, mediatype='text/markdown;charset=utf-8') -def trove_browse_link(iri: str) -> str: - _compact = namespaces_shorthand().compact_iri(iri) - return urllib.parse.urljoin( - reverse('trove:browse-iri'), - f'?blendCards&iri={urllib.parse.quote(_compact)}', - ) - - TROVE_API_THESAURUS: RdfTripleDictionary = { TROVE.search_api: { RDFS.label: {literal('trove search api', language='en')}, From b8734c78027ccef7d1a2990230a0691ea2fb1740 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Thu, 9 Oct 2025 09:43:17 -0400 Subject: [PATCH 11/20] improve admin/osf login --- templates/admin/login.html | 3 +++ templates/allauth/login_errored_cancelled.html | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 templates/admin/login.html diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 000000000..dbe59e29c --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,3 @@ +{% extends "admin/login.html" %} + +{% block content %}{{ block.super }}login with osf{% endblock %} diff --git a/templates/allauth/login_errored_cancelled.html b/templates/allauth/login_errored_cancelled.html index c850a15ec..f7a26ffe1 100644 --- a/templates/allauth/login_errored_cancelled.html +++ b/templates/allauth/login_errored_cancelled.html @@ -3,9 +3,6 @@ {% load static %} Login Failed - - -
From 8238a2b55b4b6f52b838d7125604378e90c1d353 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 17 Oct 2025 13:05:36 -0400 Subject: [PATCH 12/20] rename SimpleRendering to EntireRendering and its field rendered_content to entire_content --- .../render/test_cardsearch_atom_renderer.py | 10 +++++----- .../render/test_cardsearch_rss_renderer.py | 10 +++++----- .../trove/render/test_html_browse_renderer.py | 4 ++-- tests/trove/render/test_jsonapi_renderer.py | 18 +++++++++--------- tests/trove/render/test_jsonld_renderer.py | 18 +++++++++--------- tests/trove/render/test_simple_csv_renderer.py | 10 +++++----- .../trove/render/test_simple_json_renderer.py | 10 +++++----- tests/trove/render/test_simple_tsv_renderer.py | 10 +++++----- tests/trove/render/test_turtle_renderer.py | 18 +++++++++--------- trove/render/_base.py | 10 +++++----- trove/render/_simple_trovesearch.py | 6 +++--- trove/render/rendering/__init__.py | 4 ++-- trove/render/rendering/entire.py | 17 +++++++++++++++++ trove/render/rendering/simple.py | 17 ----------------- trove/render/rendering/streamable.py | 2 ++ trove/render/simple_json.py | 6 +++--- 16 files changed, 86 insertions(+), 84 deletions(-) create mode 100644 trove/render/rendering/entire.py delete mode 100644 trove/render/rendering/simple.py diff --git a/tests/trove/render/test_cardsearch_atom_renderer.py b/tests/trove/render/test_cardsearch_atom_renderer.py index 97b47dbf9..c07e35c3e 100644 --- a/tests/trove/render/test_cardsearch_atom_renderer.py +++ b/tests/trove/render/test_cardsearch_atom_renderer.py @@ -1,5 +1,5 @@ from trove.render.cardsearch_atom import CardsearchAtomRenderer -from trove.render.rendering import SimpleRendering +from trove.render.rendering import EntireRendering from . import _base @@ -8,9 +8,9 @@ class TestCardsearchAtomRenderer(_base.TrovesearchRendererTests): renderer_class = CardsearchAtomRenderer expected_outputs = { - 'no_results': SimpleRendering( + 'no_results': EntireRendering( mediatype='application/atom+xml', - rendered_content=( + entire_content=( b"\n" b'' b'shtrove search results' @@ -20,9 +20,9 @@ class TestCardsearchAtomRenderer(_base.TrovesearchRendererTests): b'' ), ), - 'few_results': SimpleRendering( + 'few_results': EntireRendering( mediatype='application/atom+xml', - rendered_content=( + entire_content=( b"\n" b'' b'shtrove search results' diff --git a/tests/trove/render/test_cardsearch_rss_renderer.py b/tests/trove/render/test_cardsearch_rss_renderer.py index 0c1f65f79..237a6b6da 100644 --- a/tests/trove/render/test_cardsearch_rss_renderer.py +++ b/tests/trove/render/test_cardsearch_rss_renderer.py @@ -1,5 +1,5 @@ from trove.render.cardsearch_rss import CardsearchRssRenderer -from trove.render.rendering import SimpleRendering +from trove.render.rendering import EntireRendering from . import _base @@ -8,9 +8,9 @@ class TestCardsearchRssRenderer(_base.TrovesearchRendererTests): renderer_class = CardsearchRssRenderer expected_outputs = { - 'no_results': SimpleRendering( + 'no_results': EntireRendering( mediatype='application/rss+xml', - rendered_content=( + entire_content=( b"\n" b'' b'' @@ -21,9 +21,9 @@ class TestCardsearchRssRenderer(_base.TrovesearchRendererTests): b'' ), ), - 'few_results': SimpleRendering( + 'few_results': EntireRendering( mediatype='application/rss+xml', - rendered_content=( + entire_content=( b"\n" b'' b'shtrove search results' diff --git a/tests/trove/render/test_html_browse_renderer.py b/tests/trove/render/test_html_browse_renderer.py index 2f4229376..ee740248c 100644 --- a/tests/trove/render/test_html_browse_renderer.py +++ b/tests/trove/render/test_html_browse_renderer.py @@ -7,7 +7,7 @@ # note: smoke tests only (TODO: better) -class TestCardsearchRssRenderer(_base.TrovesearchRendererTests): +class TestTrovesearchHtmlRenderer(_base.TrovesearchRendererTests): renderer_class = RdfHtmlBrowseRenderer expected_outputs = { 'no_results': { @@ -28,4 +28,4 @@ def assert_outputs_equal(self, expected_output: typing.Any, actual_output: typin self.assertEqual(actual_output.mediatype, expected_output['mediatype']) # smoke tests -- instead of asserting full rendered html page, just check the results are in there for _result_iri in expected_output['result_iris']: - self.assertIn(html.escape(_result_iri), actual_output.rendered_content) + self.assertIn(html.escape(_result_iri), actual_output.entire_content) diff --git a/tests/trove/render/test_jsonapi_renderer.py b/tests/trove/render/test_jsonapi_renderer.py index dfdcd4f93..de3019739 100644 --- a/tests/trove/render/test_jsonapi_renderer.py +++ b/tests/trove/render/test_jsonapi_renderer.py @@ -2,7 +2,7 @@ from unittest import mock from trove.render.jsonapi import RdfJsonapiRenderer -from trove.render.rendering import SimpleRendering +from trove.render.rendering import EntireRendering from trove.vocab.namespaces import BLARG from . import _base @@ -31,9 +31,9 @@ def _get_rendered_output(self, rendering): class TestJsonapiRenderer(_BaseJsonapiRendererTest): expected_outputs = { - 'simple_card': SimpleRendering( + 'simple_card': EntireRendering( mediatype='application/vnd.api+json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "data": { "id": "blarg:aCard", "type": "index-card", @@ -63,9 +63,9 @@ class TestJsonapiRenderer(_BaseJsonapiRendererTest): } }), ), - 'various_types': SimpleRendering( + 'various_types': EntireRendering( mediatype='application/vnd.api+json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "data": { "id": "blarg:aSubject", "type": "blarg:aType", @@ -86,9 +86,9 @@ class TestJsonapiRenderer(_BaseJsonapiRendererTest): class TestJsonapiSearchRenderer(_BaseJsonapiRendererTest, _base.TrovesearchJsonRendererTests): expected_outputs = { - 'no_results': SimpleRendering( + 'no_results': EntireRendering( mediatype='application/vnd.api+json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "data": { "id": "blarg:aSearch", "type": "index-card-search", @@ -101,9 +101,9 @@ class TestJsonapiSearchRenderer(_BaseJsonapiRendererTest, _base.TrovesearchJsonR } }), ), - 'few_results': SimpleRendering( + 'few_results': EntireRendering( mediatype='application/vnd.api+json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "data": { "id": "blarg:aSearchFew", "type": "index-card-search", diff --git a/tests/trove/render/test_jsonld_renderer.py b/tests/trove/render/test_jsonld_renderer.py index 6161631ea..c983cad19 100644 --- a/tests/trove/render/test_jsonld_renderer.py +++ b/tests/trove/render/test_jsonld_renderer.py @@ -1,7 +1,7 @@ import json from trove.render.jsonld import RdfJsonldRenderer -from trove.render.rendering import SimpleRendering +from trove.render.rendering import EntireRendering from trove.vocab.namespaces import BLARG from . import _base @@ -10,9 +10,9 @@ class TestJsonldRenderer(_base.TroveJsonRendererTests): renderer_class = RdfJsonldRenderer expected_outputs = { - 'simple_card': SimpleRendering( + 'simple_card': EntireRendering( mediatype='application/ld+json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "@id": "blarg:aCard", "dcterms:issued": [ { @@ -42,9 +42,9 @@ class TestJsonldRenderer(_base.TroveJsonRendererTests): } }), ), - 'various_types': SimpleRendering( + 'various_types': EntireRendering( mediatype='application/ld+json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "@id": "blarg:aSubject", "blarg:hasDateLiteral": [ { @@ -88,9 +88,9 @@ class TestJsonldSearchRenderer(_base.TrovesearchJsonRendererTests): renderer_class = RdfJsonldRenderer expected_outputs = { - 'no_results': SimpleRendering( + 'no_results': EntireRendering( mediatype='application/ld+json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "@id": "blarg:aSearch", "rdf:type": [ {"@id": "trove:Cardsearch"} @@ -101,9 +101,9 @@ class TestJsonldSearchRenderer(_base.TrovesearchJsonRendererTests): } }), ), - 'few_results': SimpleRendering( + 'few_results': EntireRendering( mediatype='application/ld+json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "@id": "blarg:aSearchFew", "rdf:type": [ {"@id": "trove:Cardsearch"} diff --git a/tests/trove/render/test_simple_csv_renderer.py b/tests/trove/render/test_simple_csv_renderer.py index eb208fa4f..cfc6379cd 100644 --- a/tests/trove/render/test_simple_csv_renderer.py +++ b/tests/trove/render/test_simple_csv_renderer.py @@ -1,5 +1,5 @@ from trove.render.simple_csv import TrovesearchSimpleCsvRenderer -from trove.render.rendering import SimpleRendering +from trove.render.rendering import EntireRendering from . import _base @@ -8,13 +8,13 @@ class TestSimpleCsvRenderer(_base.TrovesearchRendererTests): renderer_class = TrovesearchSimpleCsvRenderer expected_outputs = { - 'no_results': SimpleRendering( + 'no_results': EntireRendering( mediatype='text/csv', - rendered_content='@id,sameAs,resourceType,resourceNature,title,name,dateCreated,dateModified,rights\r\n', + entire_content='@id,sameAs,resourceType,resourceNature,title,name,dateCreated,dateModified,rights\r\n', ), - 'few_results': SimpleRendering( + 'few_results': EntireRendering( mediatype='text/csv', - rendered_content=''.join(( + entire_content=''.join(( '@id,sameAs,resourceType,resourceNature,title,name,dateCreated,dateModified,rights\r\n', 'http://blarg.example/vocab/anItem,,,,"an item, yes",,,,\r\n', 'http://blarg.example/vocab/anItemm,,,,"an itemm, yes",,,,\r\n', diff --git a/tests/trove/render/test_simple_json_renderer.py b/tests/trove/render/test_simple_json_renderer.py index 3af79414f..c2040e763 100644 --- a/tests/trove/render/test_simple_json_renderer.py +++ b/tests/trove/render/test_simple_json_renderer.py @@ -1,7 +1,7 @@ import json from trove.render.simple_json import TrovesearchSimpleJsonRenderer -from trove.render.rendering import SimpleRendering +from trove.render.rendering import EntireRendering from trove.vocab.namespaces import BLARG from . import _base @@ -11,9 +11,9 @@ class TestSimpleJsonRenderer(_base.TrovesearchJsonRendererTests): renderer_class = TrovesearchSimpleJsonRenderer expected_outputs = { - 'no_results': SimpleRendering( + 'no_results': EntireRendering( mediatype='application/json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "data": [], "links": {}, "meta": { @@ -21,9 +21,9 @@ class TestSimpleJsonRenderer(_base.TrovesearchJsonRendererTests): } }), ), - 'few_results': SimpleRendering( + 'few_results': EntireRendering( mediatype='application/json', - rendered_content=json.dumps({ + entire_content=json.dumps({ "data": [ { "@id": BLARG.anItem, diff --git a/tests/trove/render/test_simple_tsv_renderer.py b/tests/trove/render/test_simple_tsv_renderer.py index e2874501a..30f568058 100644 --- a/tests/trove/render/test_simple_tsv_renderer.py +++ b/tests/trove/render/test_simple_tsv_renderer.py @@ -1,5 +1,5 @@ from trove.render.simple_tsv import TrovesearchSimpleTsvRenderer -from trove.render.rendering import SimpleRendering +from trove.render.rendering import EntireRendering from . import _base @@ -8,13 +8,13 @@ class TestSimpleTsvRenderer(_base.TrovesearchRendererTests): renderer_class = TrovesearchSimpleTsvRenderer expected_outputs = { - 'no_results': SimpleRendering( + 'no_results': EntireRendering( mediatype='text/tab-separated-values', - rendered_content='@id\tsameAs\tresourceType\tresourceNature\ttitle\tname\tdateCreated\tdateModified\trights\r\n', + entire_content='@id\tsameAs\tresourceType\tresourceNature\ttitle\tname\tdateCreated\tdateModified\trights\r\n', ), - 'few_results': SimpleRendering( + 'few_results': EntireRendering( mediatype='text/tab-separated-values', - rendered_content=''.join(( + entire_content=''.join(( '@id\tsameAs\tresourceType\tresourceNature\ttitle\tname\tdateCreated\tdateModified\trights\r\n', 'http://blarg.example/vocab/anItem\t\t\t\tan item, yes\t\t\t\t\r\n', 'http://blarg.example/vocab/anItemm\t\t\t\tan itemm, yes\t\t\t\t\r\n', diff --git a/tests/trove/render/test_turtle_renderer.py b/tests/trove/render/test_turtle_renderer.py index be17e42f6..3bf5ee3d8 100644 --- a/tests/trove/render/test_turtle_renderer.py +++ b/tests/trove/render/test_turtle_renderer.py @@ -1,7 +1,7 @@ from primitive_metadata import primitive_rdf as rdf from trove.render.turtle import RdfTurtleRenderer -from trove.render.rendering import SimpleRendering +from trove.render.rendering import EntireRendering from . import _base @@ -14,9 +14,9 @@ def _get_rendered_output(self, rendering): class TestTurtleRenderer(_BaseTurtleRendererTest): expected_outputs = { - 'simple_card': SimpleRendering( + 'simple_card': EntireRendering( mediatype='text/turtle', - rendered_content=''' + entire_content=''' @prefix blarg: . @prefix dcat: . @prefix dcterms: . @@ -33,9 +33,9 @@ class TestTurtleRenderer(_BaseTurtleRendererTest): trove:resourceMetadata "{\\"@id\\": \\"http://blarg.example/vocab/anItem\\", \\"title\\": [{\\"@value\\": \\"an item, yes\\"}]}"^^rdf:JSON . ''', ), - 'various_types': SimpleRendering( + 'various_types': EntireRendering( mediatype='text/turtle', - rendered_content=''' + entire_content=''' @prefix blarg: . @prefix rdf: . @prefix xsd: . @@ -54,9 +54,9 @@ class TestTurtleRenderer(_BaseTurtleRendererTest): class TestTurtleTrovesearchRenderer(_BaseTurtleRendererTest, _base.TrovesearchRendererTests): expected_outputs = { - 'no_results': SimpleRendering( + 'no_results': EntireRendering( mediatype='text/turtle', - rendered_content=''' + entire_content=''' @prefix blarg: . @prefix trove: . @prefix xsd: . @@ -65,9 +65,9 @@ class TestTurtleTrovesearchRenderer(_BaseTurtleRendererTest, _base.TrovesearchRe trove:totalResultCount 0 . ''', ), - 'few_results': SimpleRendering( + 'few_results': EntireRendering( mediatype='text/turtle', - rendered_content=''' + entire_content=''' @prefix blarg: . @prefix dcat: . @prefix dcterms: . diff --git a/trove/render/_base.py b/trove/render/_base.py index 4813115ea..732b8e71d 100644 --- a/trove/render/_base.py +++ b/trove/render/_base.py @@ -13,7 +13,7 @@ from trove.vocab import mediatypes from trove.vocab.trove import TROVE_API_THESAURUS from trove.vocab.namespaces import namespaces_shorthand -from .rendering import ProtoRendering, SimpleRendering +from .rendering import ProtoRendering, EntireRendering @dataclasses.dataclass @@ -61,17 +61,17 @@ def render_document(self) -> ProtoRendering: except NotImplementedError: raise NotImplementedError(f'class "{type(self)}" must implement either `render_document` or `simple_render_document`') else: - return SimpleRendering( + return EntireRendering( mediatype=self.MEDIATYPE, - rendered_content=_content, + entire_content=_content, ) @classmethod def render_error_document(cls, error: trove_exceptions.TroveError) -> ProtoRendering: # may override, but default to jsonapi - return SimpleRendering( + return EntireRendering( mediatype=mediatypes.JSONAPI, - rendered_content=json.dumps( + entire_content=json.dumps( {'errors': [{ # https://jsonapi.org/format/#error-objects 'status': error.http_status, 'code': error.error_location, diff --git a/trove/render/_simple_trovesearch.py b/trove/render/_simple_trovesearch.py index 5ab316f62..9173da290 100644 --- a/trove/render/_simple_trovesearch.py +++ b/trove/render/_simple_trovesearch.py @@ -11,7 +11,7 @@ from trove.vocab.jsonapi import JSONAPI_LINK_OBJECT from trove.vocab.namespaces import TROVE, RDF from ._base import BaseRenderer -from .rendering import ProtoRendering, SimpleRendering +from .rendering import ProtoRendering, EntireRendering if TYPE_CHECKING: from trove.util.json import JsonObject @@ -35,9 +35,9 @@ def simple_multicard_rendering(self, cards: Iterator[tuple[str, JsonObject]]) -> def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: _cards = itertools.chain.from_iterable(card_pages) - return SimpleRendering( + return EntireRendering( mediatype=self.MEDIATYPE, - rendered_content=self.simple_multicard_rendering(_cards), + entire_content=self.simple_multicard_rendering(_cards), ) def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRendering: diff --git a/trove/render/rendering/__init__.py b/trove/render/rendering/__init__.py index 029ca9f4c..9e8cb29b8 100644 --- a/trove/render/rendering/__init__.py +++ b/trove/render/rendering/__init__.py @@ -1,4 +1,4 @@ from .proto import ProtoRendering -from .simple import SimpleRendering +from .entire import EntireRendering -__all__ = ('ProtoRendering', 'SimpleRendering') +__all__ = ('ProtoRendering', 'EntireRendering') diff --git a/trove/render/rendering/entire.py b/trove/render/rendering/entire.py new file mode 100644 index 000000000..45c7abc0f --- /dev/null +++ b/trove/render/rendering/entire.py @@ -0,0 +1,17 @@ +from collections.abc import Generator +import dataclasses + +from .proto import ProtoRendering + +__all__ = ('EntireRendering',) + + +@dataclasses.dataclass +class EntireRendering(ProtoRendering): + '''EntireRendering: for response content rendered in its entirety before being sent + ''' + mediatype: str + entire_content: str | bytes = '' + + def iter_content(self) -> Generator[str] | Generator[bytes]: + yield self.entire_content diff --git a/trove/render/rendering/simple.py b/trove/render/rendering/simple.py deleted file mode 100644 index f99f8b879..000000000 --- a/trove/render/rendering/simple.py +++ /dev/null @@ -1,17 +0,0 @@ -from collections.abc import Generator -import dataclasses - -from .proto import ProtoRendering - -__all__ = ('SimpleRendering',) - - -@dataclasses.dataclass -class SimpleRendering(ProtoRendering): - '''for simple pre-rendered string content - ''' - mediatype: str - rendered_content: str | bytes = '' - - def iter_content(self) -> Generator[str] | Generator[bytes]: - yield self.rendered_content diff --git a/trove/render/rendering/streamable.py b/trove/render/rendering/streamable.py index 5b9ad2ee6..c61ff6bcc 100644 --- a/trove/render/rendering/streamable.py +++ b/trove/render/rendering/streamable.py @@ -7,6 +7,8 @@ @dataclasses.dataclass class StreamableRendering(ProtoRendering): + '''StreamableRendering: for response content that may be rendered incrementally while being streamed + ''' mediatype: str content_stream: Iterator[str] | Iterator[bytes] = iter(()) _started_already: bool = False diff --git a/trove/render/simple_json.py b/trove/render/simple_json.py index 099184382..13a4e5c49 100644 --- a/trove/render/simple_json.py +++ b/trove/render/simple_json.py @@ -13,7 +13,7 @@ from trove.vocab.namespaces import TROVE, RDF from .rendering import ( ProtoRendering, - SimpleRendering, + EntireRendering, ) from .rendering.streamable import StreamableRendering from ._simple_trovesearch import SimpleTrovesearchRenderer @@ -32,9 +32,9 @@ class TrovesearchSimpleJsonRenderer(SimpleTrovesearchRenderer): MEDIATYPE = mediatypes.JSON def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRendering: - return SimpleRendering( + return EntireRendering( mediatype=self.MEDIATYPE, - rendered_content=json.dumps({ + entire_content=json.dumps({ 'data': self._render_card_content(card_iri, osfmap_json), 'links': self._render_links(), 'meta': self._render_meta(), From 195f7b1fa562d32654702bae9a71144e2be6b285 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 17 Oct 2025 13:16:19 -0400 Subject: [PATCH 13/20] remove simple_multicard_rendering non-convenience --- trove/render/_simple_trovesearch.py | 18 +++++------------- trove/render/cardsearch_atom.py | 14 ++++++++++---- trove/render/cardsearch_rss.py | 14 ++++++++++---- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/trove/render/_simple_trovesearch.py b/trove/render/_simple_trovesearch.py index 9173da290..1a54e5ac3 100644 --- a/trove/render/_simple_trovesearch.py +++ b/trove/render/_simple_trovesearch.py @@ -1,6 +1,6 @@ from __future__ import annotations +import abc from collections.abc import Generator, Iterator, Sequence -import itertools import json import logging from typing import Any, TYPE_CHECKING @@ -11,14 +11,14 @@ from trove.vocab.jsonapi import JSONAPI_LINK_OBJECT from trove.vocab.namespaces import TROVE, RDF from ._base import BaseRenderer -from .rendering import ProtoRendering, EntireRendering if TYPE_CHECKING: from trove.util.json import JsonObject + from trove.render.rendering import ProtoRendering _logger = logging.getLogger(__name__) -class SimpleTrovesearchRenderer(BaseRenderer): +class SimpleTrovesearchRenderer(BaseRenderer, abc.ABC): '''for "simple" search api responses (including only result metadata) (very entangled with trove/trovesearch/trovesearch_gathering.py) @@ -28,17 +28,9 @@ class SimpleTrovesearchRenderer(BaseRenderer): _page_links: set[str] # for use *after* iterating cards/card_pages __already_iterated_cards = False - def simple_multicard_rendering(self, cards: Iterator[tuple[str, JsonObject]]) -> str | bytes: - raise NotImplementedError( - f'{self.__class__.__name__} must implement either `multicard_rendering` or `simple_multicard_rendering`' - ) - + @abc.abstractmethod def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: - _cards = itertools.chain.from_iterable(card_pages) - return EntireRendering( - mediatype=self.MEDIATYPE, - entire_content=self.simple_multicard_rendering(_cards), - ) + raise NotImplementedError(f'{self.__class__.__name__} must implement `multicard_rendering`') def unicard_rendering(self, card_iri: str, osfmap_json: JsonObject) -> ProtoRendering: _page = [(card_iri, osfmap_json)] diff --git a/trove/render/cardsearch_atom.py b/trove/render/cardsearch_atom.py index 3590a446b..63a67cc0c 100644 --- a/trove/render/cardsearch_atom.py +++ b/trove/render/cardsearch_atom.py @@ -1,9 +1,11 @@ from __future__ import annotations +import itertools import typing from django.utils.translation import gettext as _ from primitive_metadata import primitive_rdf as rdf +from trove.render.rendering import EntireRendering from trove.util.datetime import datetime_isoformat_z from trove.util.json import ( json_strs, @@ -16,8 +18,9 @@ from ._simple_trovesearch import SimpleTrovesearchRenderer if typing.TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Sequence from trove.util.json import JsonObject + from trove.render.rendering import ProtoRendering class CardsearchAtomRenderer(SimpleTrovesearchRenderer): @@ -25,7 +28,7 @@ class CardsearchAtomRenderer(SimpleTrovesearchRenderer): ''' MEDIATYPE = mediatypes.ATOM - def simple_multicard_rendering(self, cards: Iterator[tuple[str, JsonObject]]) -> bytes: + def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: def _strs(*path: str) -> Iterator[str]: yield from json_strs(_osfmap_json, path, coerce_str=True) @@ -37,7 +40,7 @@ def _dates(*path: str) -> Iterator[str]: _xb.leaf('subtitle', text=_('feed of metadata records matching given filters')) _xb.leaf('link', text=self.response_focus.single_iri()) _xb.leaf('id', text=self.response_focus.single_iri()) - for _card_iri, _osfmap_json in cards: + for _card_iri, _osfmap_json in itertools.chain.from_iterable(card_pages): with _xb.nest('entry'): _iri = _osfmap_json.get('@id', _card_iri) _xb.leaf('link', {'href': _iri}) @@ -60,7 +63,10 @@ def _dates(*path: str) -> Iterator[str]: _xb.leaf('uri', text=_creator_iri) for _sameas_iri in json_strs(_creator_obj, ['sameAs']): _xb.leaf('uri', text=_sameas_iri) - return bytes(_xb) + return EntireRendering( + mediatype=self.MEDIATYPE, + entire_content=bytes(_xb), + ) def _atom_id(self, card_iri: str) -> str: try: diff --git a/trove/render/cardsearch_rss.py b/trove/render/cardsearch_rss.py index 89c8a4723..d7ddca097 100644 --- a/trove/render/cardsearch_rss.py +++ b/trove/render/cardsearch_rss.py @@ -1,10 +1,12 @@ from __future__ import annotations from email.utils import format_datetime as rfc2822_datetime +import itertools import typing from django.conf import settings from django.utils.translation import gettext as _ +from trove.render.rendering import EntireRendering from trove.util.json import ( json_datetimes, json_vals, @@ -15,8 +17,9 @@ from ._simple_trovesearch import SimpleTrovesearchRenderer if typing.TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Sequence from trove.util.json import JsonObject + from trove.render.rendering import ProtoRendering class CardsearchRssRenderer(SimpleTrovesearchRenderer): @@ -24,7 +27,7 @@ class CardsearchRssRenderer(SimpleTrovesearchRenderer): ''' MEDIATYPE = mediatypes.RSS - def simple_multicard_rendering(self, cards: Iterator[tuple[str, JsonObject]]) -> bytes: + def multicard_rendering(self, card_pages: Iterator[Sequence[tuple[str, JsonObject]]]) -> ProtoRendering: def _strs(*path: str) -> Iterator[str]: yield from json_strs(_osfmap_json, path, coerce_str=True) @@ -39,7 +42,7 @@ def _dates(*path: str) -> Iterator[str]: _xb.leaf('link', text=self.response_focus.single_iri()) _xb.leaf('description', text=_('feed of metadata records matching given filters')) _xb.leaf('webMaster', text=settings.SHARE_SUPPORT_EMAIL) - for _card_iri, _osfmap_json in cards: + for _card_iri, _osfmap_json in itertools.chain.from_iterable(card_pages): with _xb.nest('item'): # see https://www.rssboard.org/rss-specification#hrelementsOfLtitemgt _iri = _osfmap_json.get('@id', _card_iri) @@ -58,4 +61,7 @@ def _dates(*path: str) -> Iterator[str]: _creator_name = next(json_strs(_creator_obj, ['name'])) _creator_id = _creator_obj.get('@id', _creator_name) _xb.leaf('author', text=f'{_creator_id} ({_creator_name})') - return bytes(_xb) + return EntireRendering( + mediatype=self.MEDIATYPE, + entire_content=bytes(_xb), + ) From 88d1eaf0a8d80ddbe5f49673139f8d6f0187a5fe Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 17 Oct 2025 13:22:05 -0400 Subject: [PATCH 14/20] omit search-only mediatype links on non-search pages --- trove/render/html_browse.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/trove/render/html_browse.py b/trove/render/html_browse.py index 04c308ecf..28653beee 100644 --- a/trove/render/html_browse.py +++ b/trove/render/html_browse.py @@ -28,7 +28,7 @@ from trove.util.randomness import shuffled from trove.vocab import mediatypes from trove.vocab import jsonapi -from trove.vocab.namespaces import RDF, RDFS, SKOS, DCTERMS, FOAF, DC, OSFMAP +from trove.vocab.namespaces import RDF, RDFS, SKOS, DCTERMS, FOAF, DC, OSFMAP, TROVE from trove.vocab.static_vocab import combined_thesaurus__suffuniq from ._base import BaseRenderer @@ -41,6 +41,11 @@ mediatypes.TSV, mediatypes.CSV, ) +SEARCHONLY_MEDIATYPES = frozenset(( + mediatypes.JSON, + mediatypes.TSV, + mediatypes.CSV, +)) _LINK_TEXT_PREDICATES = ( SKOS.prefLabel, @@ -82,6 +87,13 @@ def __post_init__(self) -> None: def is_data_blended(self) -> bool | None: return self.response_gathering.gatherer_kwargs.get('blend_cards') + @property + def is_search(self) -> bool: + return not self.response_focus.type_iris.isdisjoint(( + TROVE.Cardsearch, + TROVE.Valuesearch, + )) + # override BaseRenderer def simple_render_document(self) -> str: self.__hb = HtmlBuilder() @@ -124,7 +136,10 @@ def render_footer(self) -> None: def __alternate_mediatypes_card(self) -> None: with self.__nest_card('details'): self.__hb.leaf('summary', text=_('alternate mediatypes')) - for _mediatype in shuffled((*STABLE_MEDIATYPES, *UNSTABLE_MEDIATYPES)): + _linked_mediatypes = {*STABLE_MEDIATYPES, *UNSTABLE_MEDIATYPES} + if not self.is_search: + _linked_mediatypes -= SEARCHONLY_MEDIATYPES + for _mediatype in shuffled(_linked_mediatypes): with self.__hb.nest('span', attrs={'class': 'Browse__literal'}): self.__mediatype_link(_mediatype) From 64c5c5be2a9fb37ad7d710b9a20c25b3cec286aa Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 17 Oct 2025 14:24:10 -0400 Subject: [PATCH 15/20] remove complicating "simple" names - SimpleTrovesearchRenderer => TrovesearchCardOnlyRenderer - remove "simple" renderer convenience methods; it's not that hard to build an EntireRendering (previously named "SimpleRendering") --- ...er.py => test_trovesearch_csv_renderer.py} | 6 +++--- ...r.py => test_trovesearch_json_renderer.py} | 6 +++--- ...er.py => test_trovesearch_tsv_renderer.py} | 6 +++--- trove/render/__init__.py | 12 ++++++------ trove/render/_base.py | 19 ++++++------------- ...ovesearch.py => _trovesearch_card_only.py} | 6 +++--- trove/render/cardsearch_atom.py | 4 ++-- trove/render/cardsearch_rss.py | 4 ++-- trove/render/html_browse.py | 9 ++++++++- trove/render/jsonapi.py | 9 +++++++-- trove/render/jsonld.py | 9 +++++++-- trove/render/simple_tsv.py | 10 ---------- .../{simple_csv.py => trovesearch_csv.py} | 4 ++-- .../{simple_json.py => trovesearch_json.py} | 4 ++-- trove/render/trovesearch_tsv.py | 10 ++++++++++ trove/render/turtle.py | 11 ++++++++--- 16 files changed, 72 insertions(+), 57 deletions(-) rename tests/trove/render/{test_simple_csv_renderer.py => test_trovesearch_csv_renderer.py} (83%) rename tests/trove/render/{test_simple_json_renderer.py => test_trovesearch_json_renderer.py} (92%) rename tests/trove/render/{test_simple_tsv_renderer.py => test_trovesearch_tsv_renderer.py} (84%) rename trove/render/{_simple_trovesearch.py => _trovesearch_card_only.py} (94%) delete mode 100644 trove/render/simple_tsv.py rename trove/render/{simple_csv.py => trovesearch_csv.py} (97%) rename trove/render/{simple_json.py => trovesearch_json.py} (96%) create mode 100644 trove/render/trovesearch_tsv.py diff --git a/tests/trove/render/test_simple_csv_renderer.py b/tests/trove/render/test_trovesearch_csv_renderer.py similarity index 83% rename from tests/trove/render/test_simple_csv_renderer.py rename to tests/trove/render/test_trovesearch_csv_renderer.py index cfc6379cd..aa31651d1 100644 --- a/tests/trove/render/test_simple_csv_renderer.py +++ b/tests/trove/render/test_trovesearch_csv_renderer.py @@ -1,12 +1,12 @@ -from trove.render.simple_csv import TrovesearchSimpleCsvRenderer +from trove.render.trovesearch_csv import TrovesearchCsvRenderer from trove.render.rendering import EntireRendering from . import _base # note: trovesearch only -- this renderer doesn't do arbitrary rdf -class TestSimpleCsvRenderer(_base.TrovesearchRendererTests): - renderer_class = TrovesearchSimpleCsvRenderer +class TestTrovesearchCsvRenderer(_base.TrovesearchRendererTests): + renderer_class = TrovesearchCsvRenderer expected_outputs = { 'no_results': EntireRendering( mediatype='text/csv', diff --git a/tests/trove/render/test_simple_json_renderer.py b/tests/trove/render/test_trovesearch_json_renderer.py similarity index 92% rename from tests/trove/render/test_simple_json_renderer.py rename to tests/trove/render/test_trovesearch_json_renderer.py index c2040e763..a0a9c4ad0 100644 --- a/tests/trove/render/test_simple_json_renderer.py +++ b/tests/trove/render/test_trovesearch_json_renderer.py @@ -1,6 +1,6 @@ import json -from trove.render.simple_json import TrovesearchSimpleJsonRenderer +from trove.render.trovesearch_json import TrovesearchJsonRenderer from trove.render.rendering import EntireRendering from trove.vocab.namespaces import BLARG from . import _base @@ -8,8 +8,8 @@ # note: trovesearch only -- this renderer doesn't do arbitrary rdf -class TestSimpleJsonRenderer(_base.TrovesearchJsonRendererTests): - renderer_class = TrovesearchSimpleJsonRenderer +class TestTrovesearchJsonRenderer(_base.TrovesearchJsonRendererTests): + renderer_class = TrovesearchJsonRenderer expected_outputs = { 'no_results': EntireRendering( mediatype='application/json', diff --git a/tests/trove/render/test_simple_tsv_renderer.py b/tests/trove/render/test_trovesearch_tsv_renderer.py similarity index 84% rename from tests/trove/render/test_simple_tsv_renderer.py rename to tests/trove/render/test_trovesearch_tsv_renderer.py index 30f568058..9d9782a82 100644 --- a/tests/trove/render/test_simple_tsv_renderer.py +++ b/tests/trove/render/test_trovesearch_tsv_renderer.py @@ -1,12 +1,12 @@ -from trove.render.simple_tsv import TrovesearchSimpleTsvRenderer +from trove.render.trovesearch_tsv import TrovesearchTsvRenderer from trove.render.rendering import EntireRendering from . import _base # note: trovesearch only -- this renderer doesn't do arbitrary rdf -class TestSimpleTsvRenderer(_base.TrovesearchRendererTests): - renderer_class = TrovesearchSimpleTsvRenderer +class TestTrovesearchTsvRenderer(_base.TrovesearchRendererTests): + renderer_class = TrovesearchTsvRenderer expected_outputs = { 'no_results': EntireRendering( mediatype='text/tab-separated-values', diff --git a/trove/render/__init__.py b/trove/render/__init__.py index 278697e63..cd3189ef2 100644 --- a/trove/render/__init__.py +++ b/trove/render/__init__.py @@ -7,11 +7,11 @@ from .html_browse import RdfHtmlBrowseRenderer from .turtle import RdfTurtleRenderer from .jsonld import RdfJsonldRenderer -from .simple_csv import TrovesearchSimpleCsvRenderer -from .simple_json import TrovesearchSimpleJsonRenderer -from .simple_tsv import TrovesearchSimpleTsvRenderer from .cardsearch_rss import CardsearchRssRenderer from .cardsearch_atom import CardsearchAtomRenderer +from .trovesearch_csv import TrovesearchCsvRenderer +from .trovesearch_json import TrovesearchJsonRenderer +from .trovesearch_tsv import TrovesearchTsvRenderer __all__ = ('get_renderer_type', 'BaseRenderer') @@ -21,9 +21,9 @@ RdfJsonapiRenderer, RdfTurtleRenderer, RdfJsonldRenderer, - TrovesearchSimpleCsvRenderer, - TrovesearchSimpleJsonRenderer, - TrovesearchSimpleTsvRenderer, + TrovesearchCsvRenderer, + TrovesearchJsonRenderer, + TrovesearchTsvRenderer, ) CARDSEARCH_ONLY_RENDERERS = ( # TODO: use/consider CardsearchRssRenderer, diff --git a/trove/render/_base.py b/trove/render/_base.py index 732b8e71d..5facde0d4 100644 --- a/trove/render/_base.py +++ b/trove/render/_base.py @@ -13,7 +13,10 @@ from trove.vocab import mediatypes from trove.vocab.trove import TROVE_API_THESAURUS from trove.vocab.namespaces import namespaces_shorthand -from .rendering import ProtoRendering, EntireRendering +from .rendering import ( + EntireRendering, + ProtoRendering, +) @dataclasses.dataclass @@ -52,19 +55,9 @@ def response_tripledict(self) -> rdf.RdfTripleDictionary: # TODO: self.response_gathering.ask_all_about or a default ask... return self.response_gathering.leaf_a_record() - def simple_render_document(self) -> str | bytes: - raise NotImplementedError - + @abc.abstractmethod def render_document(self) -> ProtoRendering: - try: - _content = self.simple_render_document() - except NotImplementedError: - raise NotImplementedError(f'class "{type(self)}" must implement either `render_document` or `simple_render_document`') - else: - return EntireRendering( - mediatype=self.MEDIATYPE, - entire_content=_content, - ) + raise NotImplementedError @classmethod def render_error_document(cls, error: trove_exceptions.TroveError) -> ProtoRendering: diff --git a/trove/render/_simple_trovesearch.py b/trove/render/_trovesearch_card_only.py similarity index 94% rename from trove/render/_simple_trovesearch.py rename to trove/render/_trovesearch_card_only.py index 1a54e5ac3..f1bc3378e 100644 --- a/trove/render/_simple_trovesearch.py +++ b/trove/render/_trovesearch_card_only.py @@ -18,10 +18,10 @@ _logger = logging.getLogger(__name__) -class SimpleTrovesearchRenderer(BaseRenderer, abc.ABC): - '''for "simple" search api responses (including only result metadata) +class TrovesearchCardOnlyRenderer(BaseRenderer, abc.ABC): + '''for search api responses that include only metadata about results - (very entangled with trove/trovesearch/trovesearch_gathering.py) + very entangled with trove/trovesearch/trovesearch_gathering.py and trove/derive/osfmap_json.py ''' PASSIVE_RENDER = False # knows the properties it cares about INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] # assumes osfmap_json diff --git a/trove/render/cardsearch_atom.py b/trove/render/cardsearch_atom.py index 63a67cc0c..f0a701881 100644 --- a/trove/render/cardsearch_atom.py +++ b/trove/render/cardsearch_atom.py @@ -15,7 +15,7 @@ from trove.util.xml import XmlBuilder from trove.vocab import mediatypes from trove.vocab.trove import trove_indexcard_namespace -from ._simple_trovesearch import SimpleTrovesearchRenderer +from ._trovesearch_card_only import TrovesearchCardOnlyRenderer if typing.TYPE_CHECKING: from collections.abc import Iterator, Sequence @@ -23,7 +23,7 @@ from trove.render.rendering import ProtoRendering -class CardsearchAtomRenderer(SimpleTrovesearchRenderer): +class CardsearchAtomRenderer(TrovesearchCardOnlyRenderer): '''render card-search results into Atom following https://www.rfc-editor.org/rfc/rfc4287 ''' MEDIATYPE = mediatypes.ATOM diff --git a/trove/render/cardsearch_rss.py b/trove/render/cardsearch_rss.py index d7ddca097..0218e47b9 100644 --- a/trove/render/cardsearch_rss.py +++ b/trove/render/cardsearch_rss.py @@ -14,7 +14,7 @@ ) from trove.util.xml import XmlBuilder from trove.vocab import mediatypes -from ._simple_trovesearch import SimpleTrovesearchRenderer +from ._trovesearch_card_only import TrovesearchCardOnlyRenderer if typing.TYPE_CHECKING: from collections.abc import Iterator, Sequence @@ -22,7 +22,7 @@ from trove.render.rendering import ProtoRendering -class CardsearchRssRenderer(SimpleTrovesearchRenderer): +class CardsearchRssRenderer(TrovesearchCardOnlyRenderer): '''render card-search results into RSS following https://www.rssboard.org/rss-specification ''' MEDIATYPE = mediatypes.RSS diff --git a/trove/render/html_browse.py b/trove/render/html_browse.py index 28653beee..bb5d3c650 100644 --- a/trove/render/html_browse.py +++ b/trove/render/html_browse.py @@ -31,6 +31,10 @@ from trove.vocab.namespaces import RDF, RDFS, SKOS, DCTERMS, FOAF, DC, OSFMAP, TROVE from trove.vocab.static_vocab import combined_thesaurus__suffuniq from ._base import BaseRenderer +from .rendering import ( + EntireRendering, + ProtoRendering, +) STABLE_MEDIATYPES = (mediatypes.JSONAPI,) UNSTABLE_MEDIATYPES = ( @@ -95,7 +99,10 @@ def is_search(self) -> bool: )) # override BaseRenderer - def simple_render_document(self) -> str: + def render_document(self) -> ProtoRendering: + return EntireRendering(self.MEDIATYPE, self.render_html_str()) + + def render_html_str(self) -> str: self.__hb = HtmlBuilder() self.render_html_head() with ( diff --git a/trove/render/jsonapi.py b/trove/render/jsonapi.py index 73bb21c61..11a78708c 100644 --- a/trove/render/jsonapi.py +++ b/trove/render/jsonapi.py @@ -33,6 +33,10 @@ ) from trove.vocab.trove import trove_indexcard_namespace from ._base import BaseRenderer +from .rendering import ( + EntireRendering, + ProtoRendering, +) # a jsonapi resource may pull rdf data using an iri or blank node @@ -84,11 +88,12 @@ class RdfJsonapiRenderer(BaseRenderer): def get_deriver_iri(cls, card_blending: bool) -> str | None: return (None if card_blending else super().get_deriver_iri(card_blending)) - def simple_render_document(self) -> str: - return json.dumps( + def render_document(self) -> ProtoRendering: + _json_str = json.dumps( self.render_dict(self.response_focus.single_iri()), indent=2, # TODO: pretty-print query param? ) + return EntireRendering(self.MEDIATYPE, _json_str) def render_dict(self, primary_iris: Union[str, Iterable[str]]) -> JsonObject: _primary_data: JsonValue = None diff --git a/trove/render/jsonld.py b/trove/render/jsonld.py index a7ca263c6..3295e4317 100644 --- a/trove/render/jsonld.py +++ b/trove/render/jsonld.py @@ -10,6 +10,10 @@ from trove.vocab.namespaces import RDF, OWL, TROVE from trove.vocab import mediatypes from ._base import BaseRenderer +from .rendering import ( + EntireRendering, + ProtoRendering, +) if TYPE_CHECKING: from trove.util.json import ( JsonObject, @@ -29,12 +33,13 @@ class RdfJsonldRenderer(BaseRenderer): __visiting_iris: set[str] | None = None - def simple_render_document(self) -> str: - return json.dumps( + def render_document(self) -> ProtoRendering: + _json_str = json.dumps( self.render_jsonld(self.response_data, self.response_focus.single_iri()), indent=2, sort_keys=True, ) + return EntireRendering(self.MEDIATYPE, _json_str) def render_jsonld( self, diff --git a/trove/render/simple_tsv.py b/trove/render/simple_tsv.py deleted file mode 100644 index 30b01a8a6..000000000 --- a/trove/render/simple_tsv.py +++ /dev/null @@ -1,10 +0,0 @@ -import csv - -from trove.vocab import mediatypes - -from .simple_csv import TrovesearchSimpleCsvRenderer - - -class TrovesearchSimpleTsvRenderer(TrovesearchSimpleCsvRenderer): - MEDIATYPE = mediatypes.TSV - CSV_DIALECT = csv.excel_tab diff --git a/trove/render/simple_csv.py b/trove/render/trovesearch_csv.py similarity index 97% rename from trove/render/simple_csv.py rename to trove/render/trovesearch_csv.py index cd14c348e..a6174f4f4 100644 --- a/trove/render/simple_csv.py +++ b/trove/render/trovesearch_csv.py @@ -20,7 +20,7 @@ from trove.util.propertypath import Propertypath, GLOB_PATHSTEP from trove.vocab import mediatypes from trove.vocab import osfmap -from ._simple_trovesearch import SimpleTrovesearchRenderer +from ._trovesearch_card_only import TrovesearchCardOnlyRenderer from .rendering import ProtoRendering from .rendering.streamable import StreamableRendering if TYPE_CHECKING: @@ -39,7 +39,7 @@ _ID_JSONPATH = ('@id',) -class TrovesearchSimpleCsvRenderer(SimpleTrovesearchRenderer): +class TrovesearchCsvRenderer(TrovesearchCardOnlyRenderer): MEDIATYPE = mediatypes.CSV CSV_DIALECT: ClassVar[type[csv.Dialect]] = csv.excel diff --git a/trove/render/simple_json.py b/trove/render/trovesearch_json.py similarity index 96% rename from trove/render/simple_json.py rename to trove/render/trovesearch_json.py index 13a4e5c49..06bd436ab 100644 --- a/trove/render/simple_json.py +++ b/trove/render/trovesearch_json.py @@ -16,7 +16,7 @@ EntireRendering, ) from .rendering.streamable import StreamableRendering -from ._simple_trovesearch import SimpleTrovesearchRenderer +from ._trovesearch_card_only import TrovesearchCardOnlyRenderer if typing.TYPE_CHECKING: from collections.abc import ( Generator, @@ -26,7 +26,7 @@ from trove.util.json import JsonObject -class TrovesearchSimpleJsonRenderer(SimpleTrovesearchRenderer): +class TrovesearchJsonRenderer(TrovesearchCardOnlyRenderer): '''for "simple json" search api -- very entangled with trove/trovesearch/trovesearch_gathering.py ''' MEDIATYPE = mediatypes.JSON diff --git a/trove/render/trovesearch_tsv.py b/trove/render/trovesearch_tsv.py new file mode 100644 index 000000000..b58882591 --- /dev/null +++ b/trove/render/trovesearch_tsv.py @@ -0,0 +1,10 @@ +import csv + +from trove.vocab import mediatypes + +from .trovesearch_csv import TrovesearchCsvRenderer + + +class TrovesearchTsvRenderer(TrovesearchCsvRenderer): + MEDIATYPE = mediatypes.TSV + CSV_DIALECT = csv.excel_tab diff --git a/trove/render/turtle.py b/trove/render/turtle.py index 869e12472..afad46e96 100644 --- a/trove/render/turtle.py +++ b/trove/render/turtle.py @@ -1,9 +1,11 @@ -from typing import Any - from primitive_metadata import primitive_rdf as rdf from trove.vocab.namespaces import TROVE from ._base import BaseRenderer +from .rendering import ( + EntireRendering, + ProtoRendering, +) class RdfTurtleRenderer(BaseRenderer): @@ -11,7 +13,10 @@ class RdfTurtleRenderer(BaseRenderer): # include indexcard metadata as JSON literals (because QuotedGraph is non-standard) INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] - def simple_render_document(self) -> Any: + def render_document(self) -> ProtoRendering: + return EntireRendering(self.MEDIATYPE, self._render_turtle()) + + def _render_turtle(self) -> str: return rdf.turtle_from_tripledict( self.response_data.tripledict, focus=self.response_focus.single_iri(), From 6a3306fc2406ad00d2a2f43a1d7685d86253d31d Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 17 Oct 2025 14:47:20 -0400 Subject: [PATCH 16/20] remove unnecessary try...else --- share/models/index_backfill.py | 3 +-- trove/derive/osfmap_json.py | 3 +-- trove/render/cardsearch_atom.py | 3 +-- trove/render/jsonld.py | 3 +-- trove/trovesearch/trovesearch_gathering.py | 3 +-- trove/util/queryparams.py | 3 +-- trove/util/trove_params.py | 3 +-- trove/views/ingest.py | 5 ++--- 8 files changed, 9 insertions(+), 17 deletions(-) diff --git a/share/models/index_backfill.py b/share/models/index_backfill.py index 93f18ab6a..7734cf292 100644 --- a/share/models/index_backfill.py +++ b/share/models/index_backfill.py @@ -185,5 +185,4 @@ def task__schedule_index_backfill(self, index_backfill_pk): except Exception as error: _index_backfill.pls_mark_error(error) raise error - else: - _index_backfill.pls_note_scheduling_has_finished() + _index_backfill.pls_note_scheduling_has_finished() diff --git a/trove/derive/osfmap_json.py b/trove/derive/osfmap_json.py index 69de39b26..21d3e2fad 100644 --- a/trove/derive/osfmap_json.py +++ b/trove/derive/osfmap_json.py @@ -151,8 +151,7 @@ def _list_or_single_value(self, predicate_iri: str, json_list: list[JsonValue]) (_only_obj,) = json_list except ValueError: return None - else: - return _only_obj + return _only_obj return ( sorted(json_list, key=json.dumps) if len(json_list) > 1 diff --git a/trove/render/cardsearch_atom.py b/trove/render/cardsearch_atom.py index f0a701881..f845e3e71 100644 --- a/trove/render/cardsearch_atom.py +++ b/trove/render/cardsearch_atom.py @@ -73,5 +73,4 @@ def _atom_id(self, card_iri: str) -> str: _uuid = rdf.iri_minus_namespace(card_iri, namespace=trove_indexcard_namespace()) except ValueError: return card_iri - else: - return f'urn:uuid:{_uuid}' + return f'urn:uuid:{_uuid}' diff --git a/trove/render/jsonld.py b/trove/render/jsonld.py index 3295e4317..5c7299f1f 100644 --- a/trove/render/jsonld.py +++ b/trove/render/jsonld.py @@ -157,8 +157,7 @@ def _list_or_single_value(self, predicate_iri: str, objectlist: list[JsonValue]) (_only_obj,) = objectlist except ValueError: return None - else: - return _only_obj + return _only_obj if predicate_iri in _PREDICATES_OF_FLEXIBLE_CARDINALITY and len(objectlist) == 1: return objectlist[0] return sorted(objectlist, key=_naive_sort_key) diff --git a/trove/trovesearch/trovesearch_gathering.py b/trove/trovesearch/trovesearch_gathering.py index beb703e68..8b3b16a6e 100644 --- a/trove/trovesearch/trovesearch_gathering.py +++ b/trove/trovesearch/trovesearch_gathering.py @@ -497,8 +497,7 @@ def _osfmap_or_unknown_iri_as_json(iri: str) -> rdf.Literal: _twopledict = osfmap.OSFMAP_THESAURUS[iri] except KeyError: return rdf.literal_json({'@id': iri}) - else: - return _osfmap_json({iri: _twopledict}, focus_iri=iri) + return _osfmap_json({iri: _twopledict}, focus_iri=iri) def _valuesearch_result_as_json(result: ValuesearchResult) -> rdf.Literal: diff --git a/trove/util/queryparams.py b/trove/util/queryparams.py index 664e63971..feb85c898 100644 --- a/trove/util/queryparams.py +++ b/trove/util/queryparams.py @@ -113,8 +113,7 @@ def get_single_value( (_singlevalue,) = _paramvalues except ValueError: raise trove_exceptions.InvalidRepeatedQueryParam(str(queryparam_name)) - else: - return _singlevalue + return _singlevalue def get_bool_value( diff --git a/trove/util/trove_params.py b/trove/util/trove_params.py index 8801e7d5b..77633841d 100644 --- a/trove/util/trove_params.py +++ b/trove/util/trove_params.py @@ -72,8 +72,7 @@ def _gather_shorthand(cls, queryparams: _qp.QueryparamDict) -> rdf.IriShorthand: (_shortname,) = _qp_name.bracketed_names except ValueError: raise trove_exceptions.InvalidQueryParamName(_qp_name) - else: - _prefixmap[_shortname] = _iri + _prefixmap[_shortname] = _iri _shorthand = cls._default_shorthand() if _prefixmap: _shorthand = _shorthand.with_update(_prefixmap) diff --git a/trove/views/ingest.py b/trove/views/ingest.py index a6b21590a..4c634bf00 100644 --- a/trove/views/ingest.py +++ b/trove/views/ingest.py @@ -61,9 +61,8 @@ def post(self, request: HttpRequest) -> HttpResponse: except trove_exceptions.DigestiveError as e: logger.exception(str(e)) return http.HttpResponse(str(e), status=HTTPStatus.BAD_REQUEST) - else: - # TODO: include (link to?) extracted card(s) - return http.HttpResponse(status=HTTPStatus.CREATED) + # TODO: include (link to?) extracted card(s) + return http.HttpResponse(status=HTTPStatus.CREATED) def delete(self, request: HttpRequest) -> HttpResponse: # TODO: cleaner permissions From a041b5d549eb430314bdc5a76363cccea6201675 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Thu, 23 Oct 2025 13:16:08 -0400 Subject: [PATCH 17/20] fix rss/atom validation errors --- .../render/test_cardsearch_atom_renderer.py | 16 ++++++++++++++-- .../render/test_cardsearch_rss_renderer.py | 9 ++++++--- trove/render/cardsearch_atom.py | 17 ++++++++++++----- trove/render/cardsearch_rss.py | 16 ++++++++++++---- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/tests/trove/render/test_cardsearch_atom_renderer.py b/tests/trove/render/test_cardsearch_atom_renderer.py index c07e35c3e..bd8d7d9c4 100644 --- a/tests/trove/render/test_cardsearch_atom_renderer.py +++ b/tests/trove/render/test_cardsearch_atom_renderer.py @@ -1,3 +1,6 @@ +from unittest import mock +import datetime + from trove.render.cardsearch_atom import CardsearchAtomRenderer from trove.render.rendering import EntireRendering from . import _base @@ -15,8 +18,9 @@ class TestCardsearchAtomRenderer(_base.TrovesearchRendererTests): b'' b'shtrove search results' b'feed of metadata records matching given filters' - b'http://blarg.example/vocab/aSearch' + b'' b'http://blarg.example/vocab/aSearch' + b'2345-06-07T08:09:10Z' b'' ), ), @@ -27,8 +31,9 @@ class TestCardsearchAtomRenderer(_base.TrovesearchRendererTests): b'' b'shtrove search results' b'feed of metadata records matching given filters' - b'http://blarg.example/vocab/aSearchFew' + b'' b'http://blarg.example/vocab/aSearchFew' + b'2345-06-07T08:09:10Z' b'' b'' b'http://blarg.example/vocab/aCard' @@ -42,7 +47,14 @@ class TestCardsearchAtomRenderer(_base.TrovesearchRendererTests): b'http://blarg.example/vocab/aCarddd' b'an itemmm, yes' b'2001-02-03T00:00:00Z' + b'a person indeedhttp://blarg.example/vocab/aPerson' b'' ), ), } + + def setUp(self): + self.enterContext(mock.patch( + 'django.utils.timezone.now', + return_value=datetime.datetime(2345, 6, 7, 8, 9, 10, tzinfo=datetime.UTC), + )) diff --git a/tests/trove/render/test_cardsearch_rss_renderer.py b/tests/trove/render/test_cardsearch_rss_renderer.py index 237a6b6da..a376b6cda 100644 --- a/tests/trove/render/test_cardsearch_rss_renderer.py +++ b/tests/trove/render/test_cardsearch_rss_renderer.py @@ -12,10 +12,11 @@ class TestCardsearchRssRenderer(_base.TrovesearchRendererTests): mediatype='application/rss+xml', entire_content=( b"\n" - b'' + b'' b'' b'shtrove search results' b'http://blarg.example/vocab/aSearch' + b'' b'feed of metadata records matching given filters' b'share-support@cos.io' b'' @@ -25,9 +26,11 @@ class TestCardsearchRssRenderer(_base.TrovesearchRendererTests): mediatype='application/rss+xml', entire_content=( b"\n" - b'' + b'' + b'' b'shtrove search results' b'http://blarg.example/vocab/aSearchFew' + b'' b'feed of metadata records matching given filters' b'share-support@cos.io' b'' @@ -43,7 +46,7 @@ class TestCardsearchRssRenderer(_base.TrovesearchRendererTests): b'http://blarg.example/vocab/anItemmm' b'an itemmm, yes' b'Sat, 03 Feb 2001 00:00:00 -0000' - b'http://blarg.example/vocab/aPerson (a person indeed)' + b'http://blarg.example/vocab/aPerson (a person indeed)' b'' ), ), diff --git a/trove/render/cardsearch_atom.py b/trove/render/cardsearch_atom.py index f845e3e71..9d8188b1d 100644 --- a/trove/render/cardsearch_atom.py +++ b/trove/render/cardsearch_atom.py @@ -2,6 +2,7 @@ import itertools import typing +from django.utils import timezone from django.utils.translation import gettext as _ from primitive_metadata import primitive_rdf as rdf @@ -38,8 +39,9 @@ def _dates(*path: str) -> Iterator[str]: _xb = XmlBuilder('feed', {'xmlns': 'http://www.w3.org/2005/Atom'}) _xb.leaf('title', text=_('shtrove search results')) _xb.leaf('subtitle', text=_('feed of metadata records matching given filters')) - _xb.leaf('link', text=self.response_focus.single_iri()) + _xb.leaf('link', {'href': self.response_focus.single_iri()}) _xb.leaf('id', text=self.response_focus.single_iri()) + _xb.leaf('updated', text=datetime_isoformat_z(timezone.now())) for _card_iri, _osfmap_json in itertools.chain.from_iterable(card_pages): with _xb.nest('entry'): _iri = _osfmap_json.get('@id', _card_iri) @@ -47,13 +49,20 @@ def _dates(*path: str) -> Iterator[str]: _xb.leaf('id', text=self._atom_id(_card_iri)) for _title in _strs('title'): _xb.leaf('title', text=_title) + for _filename in _strs('fileName'): + _xb.leaf('title', text=_filename) for _desc in _strs('description'): _xb.leaf('summary', text=_desc) for _keyword in _strs('keyword'): - _xb.leaf('category', text=_keyword) + _xb.leaf('category', {'term': _keyword}) for _created in _dates('dateCreated'): _xb.leaf('published', text=_created) - for _creator_obj in json_vals(_osfmap_json, 'creator'): + for _modified in _dates('dateModified'): + _xb.leaf('updated', text=_modified) + _creator_objs = list(json_vals(_osfmap_json, ['creator'])) + if not _creator_objs: + _creator_objs = list(json_vals(_osfmap_json, ['isContainedBy', 'creator'])) + for _creator_obj in _creator_objs: assert isinstance(_creator_obj, dict) with _xb.nest('author'): for _name in json_strs(_creator_obj, ['name']): @@ -61,8 +70,6 @@ def _dates(*path: str) -> Iterator[str]: _creator_iri = _creator_obj.get('@id') if _creator_iri: _xb.leaf('uri', text=_creator_iri) - for _sameas_iri in json_strs(_creator_obj, ['sameAs']): - _xb.leaf('uri', text=_sameas_iri) return EntireRendering( mediatype=self.MEDIATYPE, entire_content=bytes(_xb), diff --git a/trove/render/cardsearch_rss.py b/trove/render/cardsearch_rss.py index 0218e47b9..2d93ea54a 100644 --- a/trove/render/cardsearch_rss.py +++ b/trove/render/cardsearch_rss.py @@ -35,11 +35,19 @@ def _dates(*path: str) -> Iterator[str]: for _dt in json_datetimes(_osfmap_json, path): yield rfc2822_datetime(_dt) - _xb = XmlBuilder('rss', {'version': '2.0'}) + _xb = XmlBuilder('rss', { + 'version': '2.0', + 'xmlns:dc': 'http://purl.org/dc/elements/1.1/', + 'xmlns:atom': 'http://www.w3.org/2005/Atom', + }) with _xb.nest('channel'): # see https://www.rssboard.org/rss-specification#requiredChannelElements _xb.leaf('title', text=_('shtrove search results')) _xb.leaf('link', text=self.response_focus.single_iri()) + _xb.leaf('atom:link', { + 'rel': 'self', + 'href': self.response_focus.single_iri(), + }) _xb.leaf('description', text=_('feed of metadata records matching given filters')) _xb.leaf('webMaster', text=settings.SHARE_SUPPORT_EMAIL) for _card_iri, _osfmap_json in itertools.chain.from_iterable(card_pages): @@ -48,8 +56,8 @@ def _dates(*path: str) -> Iterator[str]: _iri = _osfmap_json.get('@id', _card_iri) _xb.leaf('link', text=_iri) _xb.leaf('guid', {'isPermaLink': 'true'}, text=_iri) - for _title in _strs('title'): - _xb.leaf('title', text=_title) + _titles = itertools.chain(_strs('title'), _strs('fileName')) + _xb.leaf('title', text=next(_titles, '')) for _desc in _strs('description'): _xb.leaf('description', text=_desc) for _keyword in _strs('keyword'): @@ -60,7 +68,7 @@ def _dates(*path: str) -> Iterator[str]: assert isinstance(_creator_obj, dict) _creator_name = next(json_strs(_creator_obj, ['name'])) _creator_id = _creator_obj.get('@id', _creator_name) - _xb.leaf('author', text=f'{_creator_id} ({_creator_name})') + _xb.leaf('dc:creator', text=f'{_creator_id} ({_creator_name})') return EntireRendering( mediatype=self.MEDIATYPE, entire_content=bytes(_xb), From 8d486496142cde36ea823e3a5cadc417046e3cb0 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Fri, 24 Oct 2025 12:05:11 -0400 Subject: [PATCH 18/20] add feed links to json renderer --- trove/render/trovesearch_json.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/trove/render/trovesearch_json.py b/trove/render/trovesearch_json.py index 06bd436ab..e5b4b4087 100644 --- a/trove/render/trovesearch_json.py +++ b/trove/render/trovesearch_json.py @@ -1,4 +1,5 @@ from __future__ import annotations +import itertools import json import re import typing @@ -6,6 +7,7 @@ from primitive_metadata import primitive_rdf as rdf from trove.vocab.jsonapi import ( + JSONAPI_LINK, JSONAPI_LINK_OBJECT, JSONAPI_MEMBERNAME, ) @@ -91,8 +93,9 @@ def _render_meta(self) -> dict[str, int | str]: def _render_links(self) -> JsonObject: _links = {} - for _pagelink in self._page_links: - _twopledict = rdf.twopledict_from_twopleset(_pagelink) + _response_links = self.response_gathering.ask(JSONAPI_LINK, focus=self.response_focus) + for _link_obj in itertools.chain(self._page_links, _response_links): + _twopledict = rdf.twopledict_from_twopleset(_link_obj) if JSONAPI_LINK_OBJECT in _twopledict.get(RDF.type, ()): (_membername,) = _twopledict[JSONAPI_MEMBERNAME] (_link_url,) = _twopledict[RDF.value] From 97cc5597647eff69e571c9de7bace8caaaa60e0e Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Thu, 30 Oct 2025 11:42:01 -0400 Subject: [PATCH 19/20] update dependencies celery 5.5.3 (from 5.4.0) kombu 5.5.4 (from 5.5.0) pin django 5.2.7 (previously wildcard 5.2.*) --- poetry.lock | 71 +++++++++++++++++++++++++------------------------- pyproject.toml | 6 ++--- 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3f1f85e66..134c106be 100644 --- a/poetry.lock +++ b/poetry.lock @@ -109,60 +109,60 @@ files = [ [[package]] name = "celery" -version = "5.4.0" +version = "5.5.3" description = "Distributed Task Queue." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "celery-5.4.0-py3-none-any.whl", hash = "sha256:369631eb580cf8c51a82721ec538684994f8277637edde2dfc0dacd73ed97f64"}, - {file = "celery-5.4.0.tar.gz", hash = "sha256:504a19140e8d3029d5acad88330c541d4c3f64c789d85f94756762d8bca7e706"}, + {file = "celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525"}, + {file = "celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5"}, ] [package.dependencies] -billiard = ">=4.2.0,<5.0" +billiard = ">=4.2.1,<5.0" click = ">=8.1.2,<9.0" click-didyoumean = ">=0.3.0" click-plugins = ">=1.1.1" click-repl = ">=0.2.0" -kombu = ">=5.3.4,<6.0" +kombu = ">=5.5.2,<5.6" python-dateutil = ">=2.8.2" -tzdata = ">=2022.7" vine = ">=5.1.0,<6.0" [package.extras] arangodb = ["pyArango (>=2.0.2)"] -auth = ["cryptography (==42.0.5)"] -azureblockblob = ["azure-storage-blob (>=12.15.0)"] +auth = ["cryptography (==44.0.2)"] +azureblockblob = ["azure-identity (>=1.19.0)", "azure-storage-blob (>=12.15.0)"] brotli = ["brotli (>=1.0.0) ; platform_python_implementation == \"CPython\"", "brotlipy (>=0.7.0) ; platform_python_implementation == \"PyPy\""] cassandra = ["cassandra-driver (>=3.25.0,<4)"] consul = ["python-consul2 (==0.1.5)"] cosmosdbsql = ["pydocumentdb (==2.3.5)"] couchbase = ["couchbase (>=3.0.0) ; platform_python_implementation != \"PyPy\" and (platform_system != \"Windows\" or python_version < \"3.10\")"] -couchdb = ["pycouchdb (==1.14.2)"] +couchdb = ["pycouchdb (==1.16.0)"] django = ["Django (>=2.2.28)"] dynamodb = ["boto3 (>=1.26.143)"] -elasticsearch = ["elastic-transport (<=8.13.0)", "elasticsearch (<=8.13.0)"] +elasticsearch = ["elastic-transport (<=8.17.1)", "elasticsearch (<=8.17.2)"] eventlet = ["eventlet (>=0.32.0) ; python_version < \"3.10\""] -gcs = ["google-cloud-storage (>=2.10.0)"] +gcs = ["google-cloud-firestore (==2.20.1)", "google-cloud-storage (>=2.10.0)", "grpcio (==1.67.0)"] gevent = ["gevent (>=1.5.0)"] librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] memcache = ["pylibmc (==1.6.3) ; platform_system != \"Windows\""] -mongodb = ["pymongo[srv] (>=4.0.2)"] -msgpack = ["msgpack (==1.0.8)"] +mongodb = ["kombu[mongodb]"] +msgpack = ["kombu[msgpack]"] +pydantic = ["pydantic (>=2.4)"] pymemcache = ["python-memcached (>=1.61)"] pyro = ["pyro4 (==4.82) ; python_version < \"3.11\""] -pytest = ["pytest-celery[all] (>=1.0.0)"] -redis = ["redis (>=4.5.2,!=4.5.5,<6.0.0)"] +pytest = ["pytest-celery[all] (>=1.2.0,<1.3.0)"] +redis = ["kombu[redis]"] s3 = ["boto3 (>=1.26.143)"] -slmq = ["softlayer-messaging (>=1.0.3)"] -solar = ["ephem (==4.1.5) ; platform_python_implementation != \"PyPy\""] -sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] -sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.3.4)", "pycurl (>=7.43.0.5) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16)"] +slmq = ["softlayer_messaging (>=1.0.3)"] +solar = ["ephem (==4.2) ; platform_python_implementation != \"PyPy\""] +sqlalchemy = ["kombu[sqlalchemy]"] +sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.5.0)", "urllib3 (>=1.26.16)"] tblib = ["tblib (>=1.3.0) ; python_version < \"3.8.0\"", "tblib (>=1.5.0) ; python_version >= \"3.8.0\""] -yaml = ["PyYAML (>=3.10)"] +yaml = ["kombu[yaml]"] zookeeper = ["kazoo (>=1.3.1)"] -zstd = ["zstandard (==0.22.0)"] +zstd = ["zstandard (==0.23.0)"] [[package]] name = "certifi" @@ -617,14 +617,14 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "django" -version = "5.2.3" +version = "5.2.7" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "django-5.2.3-py3-none-any.whl", hash = "sha256:c517a6334e0fd940066aa9467b29401b93c37cec2e61365d663b80922542069d"}, - {file = "django-5.2.3.tar.gz", hash = "sha256:335213277666ab2c5cac44a792a6d2f3d58eb79a80c14b6b160cd4afc3b75684"}, + {file = "django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b"}, + {file = "django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd"}, ] [package.dependencies] @@ -1102,19 +1102,20 @@ typing-extensions = ">=4.5.0" [[package]] name = "kombu" -version = "5.5.0" +version = "5.5.4" description = "Messaging library for Python." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "kombu-5.5.0-py3-none-any.whl", hash = "sha256:526c6cf038c986b998639109a1eb762502f831e8da148cc928f1f95cd91eb874"}, - {file = "kombu-5.5.0.tar.gz", hash = "sha256:72e65c062e903ee1b4e8b68d348f63c02afc172eda409e3aca85867752e79c0b"}, + {file = "kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8"}, + {file = "kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363"}, ] [package.dependencies] amqp = ">=5.1.1,<6.0.0" -tzdata = {version = "2025.1", markers = "python_version >= \"3.9\""} +packaging = "*" +tzdata = {version = ">=2025.2", markers = "python_version >= \"3.9\""} vine = "5.1.0" [package.extras] @@ -1124,12 +1125,12 @@ confluentkafka = ["confluent-kafka (>=2.2.0)"] consul = ["python-consul2 (==0.1.5)"] gcpubsub = ["google-cloud-monitoring (>=2.16.0)", "google-cloud-pubsub (>=2.18.4)", "grpcio (==1.67.0)", "protobuf (==4.25.5)"] librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] -mongodb = ["pymongo (>=4.1.1)"] +mongodb = ["pymongo (==4.10.1)"] msgpack = ["msgpack (==1.1.0)"] pyro = ["pyro4 (==4.82)"] qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<=5.2.1)"] -slmq = ["softlayer-messaging (>=1.0.3)"] +slmq = ["softlayer_messaging (>=1.0.3)"] sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] sqs = ["boto3 (>=1.26.143)", "urllib3 (>=1.26.16)"] yaml = ["PyYAML (>=3.10)"] @@ -1451,7 +1452,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1893,14 +1894,14 @@ files = [ [[package]] name = "tzdata" -version = "2025.1" +version = "2025.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main", "dev"] files = [ - {file = "tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639"}, - {file = "tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694"}, + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] [[package]] @@ -2033,4 +2034,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = ">=3.13,<3.14" -content-hash = "cb2722bceed3082c7039af5a541855a7ce39531401e843dddb0e6493b604adeb" +content-hash = "56cd9b3cb1ce48fa9c677fd6659ae943b48ab7f8829116f3628d4de77f1a342e" diff --git a/pyproject.toml b/pyproject.toml index 3dd8fa038..ea158b7b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" requires-python = ">=3.13,<3.14" dependencies = [ "bcrypt==4.3.0", # Apache 2.0 - "celery==5.4.0", # BSD 3 Clause + "celery==5.5.3", # BSD 3 Clause "colorlog==6.9.0", # MIT "django-allauth==65.5.0", # MIT "django-celery-beat==2.8.1", # BSD 3 Clause @@ -17,10 +17,10 @@ dependencies = [ "django-extensions==3.2.3", # MIT "django-filter==25.1", # BSD "django-oauth-toolkit==3.0.1", # BSD - "django==5.2.*", # BSD 3 Clause + "django==5.2.7", # BSD 3 Clause "elasticsearch8==8.17.2", # Apache 2.0 "lxml==5.3.0", # BSD - "kombu==5.5.0", # BSD 3 Clause + "kombu==5.5.4", # BSD 3 Clause "markdown2==2.5.3", # MIT "psycopg2==2.9.10", # LGPL with exceptions or ZPL "rdflib==7.1.3", # BSD 3 Clause From 064971dddbb5fd5c8649bc8a28cf0bc574a4d5b5 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Thu, 30 Oct 2025 11:53:53 -0400 Subject: [PATCH 20/20] prepare 25.6.0 --- CHANGELOG.md | 20 ++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8af0c86a..e2b4dab9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Change Log +# [25.6.0] - 2025-10-30 +- bump dependencies + - `celery` to 5.5.3 + - `kombu` to 5.5.4 +- improve error handling in celery task-result backend +- use logging config in celery worker +- improve code docs (README.md et al.) +- add cardsearch feeds (rss and atom) + - /trove/index-card-search/rss.xml + - /trove/index-card-search/atom.xml +- fix: render >1 result in streamed index-value-search (csv, tsv, json) +- when browsing trove api in browser, wrap non-browser-friendly mediatypes in html (unless `withFileName`, which requests download) +- better trove.render test coverage +- code cleanliness + - de-collide "simple" names + - SimpleRendering => EntireRendering + - SimpleTrovesearchRenderer => TrovesearchCardOnlyRenderer + - consolidate more shared logic into trove.util + - more accurate type annotations + # [25.5.0] - 2025-07-15 - use python 3.13 - use `poetry` to manage dependencies diff --git a/pyproject.toml b/pyproject.toml index ea158b7b1..1680388b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shtrove" -version = "25.5.0" +version = "25.6.0" description = "" authors = [ {name = "CenterForOpenScience", email = "share-support@cos.io"}