diff --git a/README.md b/README.md index a24cf07..f55d988 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,22 @@ For headless environments (no browser available): client = BrainstemClient(headless=True) # prints a URL + code to enter manually ``` +To read public data without logging in (no token is sent; only `portal='public'` works): +```python +client = BrainstemClient(anonymous=True) +client.load("project", portal="public", load_all=True) +``` + To connect to a different server (e.g. a local development instance): ```python client = BrainstemClient(url="http://127.0.0.1:8000/") ``` +The client and the CLI also read two environment variables when no `token` / `url` is given: + +- `BRAINSTEM_API_TOKEN`: API token, used before the cached token. +- `BRAINSTEM_API_URL`: server base URL (default `https://www.brainstem.org/`). + > **Security note:** The cached token file is stored with owner-read-only permissions (`0600`). Treat it like a password and do not commit it to version control. ## Getting Started @@ -46,7 +57,7 @@ The tutorial demonstrates how to: - **Authenticate:** Load the client and authenticate via your browser. - **Load Data:** Load sessions and filter data using flexible options. -- **Paginate:** Retrieve large datasets using `limit` / `offset` or `load_all=True`. +- **Paginate:** Retrieve large datasets page by page (`limit` / `offset`) or all at once (`load_all=True`). - **Convenience Loaders:** Use `load_session()`, `load_subject()`, etc. for common queries. - **Update Entries:** Modify existing records and update them in the database. - **Create Entries:** Submit new data entries with required fields. @@ -71,26 +82,32 @@ response = client.load( include=['projects'], ) -# Paginate results (manual) +# Paginate results (manual). The API pages with page/per_page: limit is sent +# as per_page (1 to 500, default 100) and offset is converted to a page number, +# so offset must be a multiple of limit. Here: page 2 of 20 records. page2 = client.load('session', limit=20, offset=20).json() +print(page2['meta']) # {'page': 2, 'per_page': 20, 'total_results': ..., 'total_pages': ...} -# Auto-paginate — fetches every page and returns a merged dict +# Auto-paginate — fetches every page and returns a merged dict. Records of +# sideloaded relations (include=[...]) are merged per key as well. all_sessions = client.load('session', load_all=True) -# Convenience loaders — sensible defaults with named filter kwargs -# load_session embeds dataacquisition, behaviors, manipulations, epochs +# Convenience loaders — sensible defaults with named filter kwargs. Their +# filters send the bare field (filter{projects}=), which every server accepts. +# load_session sideloads dataacquisition, behaviors, manipulations, epochs sessions = client.load_session(name='Rat', load_all=True) -# load_subject embeds procedures and subjectlogs +# load_subject sideloads procedures and subjectlogs subjects = client.load_subject(sex='M', projects='', load_all=True) -# load_project embeds sessions, subjects, collections, cohorts +# load_project sideloads sessions, subjects, collections, cohorts projects = client.load_project(name='MyProject') # load_behavior / load_dataacquisition / load_manipulation scope by session UUID behaviors = client.load_behavior(session='', load_all=True) -# load_procedure scopes by subject UUID +# load_procedure / load_subjectlog scope by subject UUID; load_procedurelog by +# procedure, load_equipment by setup and load_strain by species UUID. procedures = client.load_procedure(subject='', load_all=True) # Create a record @@ -99,10 +116,48 @@ client.save('session', data={'name': 'New session', 'projects': [' # Update a record client.save('session', id='', data={'description': 'updated'}) +# Sending a list field replaces the whole list. Lists read with include (and +# the convenience loaders' default includes) hold only records you may read, +# so read the record without include before changing one: +session = client.load('session', id='').json()['session'] +client.save('session', id='', + data={'projects': session['projects'] + ['']}) + # Delete a record client.delete('session', id='') ``` +### Specimens, interfaces and materials + +Specimen sets and specimens (`stem`), experimental interfaces, material usages and specimen procedures/logs (`modules`), and anatomical regions (`taxonomies`) load like any other model: + +```python +specimen_sets = client.load('specimenset', filters={'projects': ''}, load_all=True) +interfaces = client.load('experimentalinterface', load_all=True) +``` + +### Species, strains and brain regions + +```python +# Search species and strains by common name as well as scientific name +mice = client.load_species(popular_name='mouse').json() +strains = client.load_strain(popular_name='black 6').json() + +# Brain regions are anatomical regions; find one by acronym, then list its subregions +ca1 = client.load_anatomicalregion(acronym='CA1').json()['anatomical_regions'][0] +subregions = client.load_anatomicalregion_descendants(ca1['id']).json()['anatomical_regions'] +for region in subregions: + print(region['depth'], region['acronym'], region['name']) +``` + +`brainregion` and `load_brainregion()` are deprecated names for `anatomicalregion` and `load_anatomicalregion()`. They still work, with a `FutureWarning`, and load anatomical regions. + +### Known limitations + +- Specimen-era models (specimen sets, specimens, experimental interfaces, material usages, anatomical regions, ...), `popular_name` and region descendants need a server with the specimen release. Older servers answer 404 for those models and 400 for a `popular_name` filter. +- `offset` must be a multiple of the page size (`limit`, default 100), and a page holds at most 500 records. Use `load_all=True` to get everything. +- Lists read with `include` show only the related records you may read; see the note on `save` above. + ## Exporting projects and sessions `export_project()` returns a whole project as one JSON-serializable dict. It is the same file as the JSON download on the BrainSTEM website (**Project → Download → JSON**). Use it for backups, NWB conversion, external integrations or analysis pipelines. You need read access to the project. @@ -210,8 +265,12 @@ brainstem load session --filters name.icontains=Rat --sort -name --include proje # Load a single record by UUID brainstem load session --id -# Manual pagination -brainstem load session --limit 20 --offset 20 +# Manual pagination (page 2, 20 records per page) +brainstem load session --per-page 20 --page 2 +brainstem load session --limit 20 --offset 20 # equivalent + +# Fetch every page and print one merged JSON object +brainstem load session --all # Public portal brainstem load project --portal public @@ -231,7 +290,7 @@ brainstem save session --id --data '{"description":"updated"}' brainstem delete session --id ``` -All subcommands accept `--token`, `--headless`, and `--url` to override defaults. +All subcommands accept `--token`, `--headless`, and `--url` to override defaults (`--token` and `--url` default to `$BRAINSTEM_API_TOKEN` and `$BRAINSTEM_API_URL`). `brainstem login` never prints the token; it reports where it is cached. ## Contributing Contributions are welcome! Feel free to open issues or submit pull requests on GitHub. diff --git a/brainstem_api_tools/brainstem_api_client.py b/brainstem_api_tools/brainstem_api_client.py index 6d12632..7b6c913 100644 --- a/brainstem_api_tools/brainstem_api_client.py +++ b/brainstem_api_tools/brainstem_api_client.py @@ -4,6 +4,7 @@ import os import stat import time +import warnings import webbrowser from enum import Enum from pathlib import Path @@ -21,68 +22,134 @@ _MODEL_TO_APP: dict = { # stem + "breeding": "stem", + "cohort": "stem", + "cohortsubject": "stem", + "collection": "stem", + "collectionsession": "stem", + "epoch": "stem", "project": "stem", - "subject": "stem", + "projectgroupmembershipinvitation": "stem", + "projectmembershipinvitation": "stem", "session": "stem", - "collection": "stem", - "cohort": "stem", - "breeding": "stem", - "project_membership_invitation": "stem", - "project_group_membership_invitation": "stem", + "sessiondatastorage": "stem", + "specimen": "stem", + "specimenset": "stem", + "subject": "stem", # modules - "procedure": "modules", "behavior": "modules", + "consumablestock": "modules", "dataacquisition": "modules", - "manipulation": "modules", + "dataacquisitioninterface": "modules", + "dataacquisitionprocedure": "modules", + "dataacquisitiontemplate": "modules", + "episodepersonnel": "modules", "equipment": "modules", - "consumablestock": "modules", + "experimentalinterface": "modules", + "experimentalinterfacetemplate": "modules", + "manipulation": "modules", + "manipulationinterface": "modules", + "manipulationtemplate": "modules", + "materialusage": "modules", + "materialusagetemplate": "modules", + "procedure": "modules", + "procedureepisode": "modules", + "procedureepisodetemplate": "modules", "procedurelog": "modules", + "proceduretemplate": "modules", + "specimenlog": "modules", + "specimenprocedure": "modules", + "specimenproceduretemplate": "modules", "subjectlog": "modules", # personal_attributes "behavioralassay": "personal_attributes", + "behavioralassaygroupmembershipinvitation": "personal_attributes", + "behavioralassaymembershipinvitation": "personal_attributes", "datastorage": "personal_attributes", + "datastoragegroupmembershipinvitation": "personal_attributes", + "datastoragemembershipinvitation": "personal_attributes", "inventory": "personal_attributes", + "inventorygroupmembershipinvitation": "personal_attributes", + "inventorymembershipinvitation": "personal_attributes", "license": "personal_attributes", + "licensegroupmembershipinvitation": "personal_attributes", + "licensemembershipinvitation": "personal_attributes", "protocol": "personal_attributes", + "protocolgroupmembershipinvitation": "personal_attributes", + "protocolmembershipinvitation": "personal_attributes", "setup": "personal_attributes", + "setupgroupmembershipinvitation": "personal_attributes", + "setupmembershipinvitation": "personal_attributes", # resources "consumable": "resources", + "consumableapproval": "resources", "hardwaredevice": "resources", + "hardwaredeviceapproval": "resources", "supplier": "resources", + "supplierapproval": "resources", # taxonomies + "anatomicalreference": "taxonomies", + "anatomicalregion": "taxonomies", "behavioralcategory": "taxonomies", + "behavioralcategoryapproval": "taxonomies", "behavioralparadigm": "taxonomies", - "brainregion": "taxonomies", + "behavioralparadigmapproval": "taxonomies", + "coordinatetransform": "taxonomies", "regulatoryauthority": "taxonomies", + "regulatoryauthorityapproval": "taxonomies", "setuptype": "taxonomies", + "setuptypeapproval": "taxonomies", "species": "taxonomies", + "speciesapproval": "taxonomies", "strain": "taxonomies", "strainapproval": "taxonomies", # dissemination "journal": "dissemination", "journalapproval": "dissemination", "publication": "dissemination", + "publicationapproval": "dissemination", # users + "admingroup": "users", "group": "users", - "group_membership_invitation": "users", - "group_membership_request": "users", + "groupmembershipinvitation": "users", + "groupmembershiprequest": "users", "laboratory": "users", + "pendinginvitation": "users", "user": "users", } _TOKEN_FILE = Path.home() / ".config" / "brainstem" / "token" +# Deprecated model names, resolved to the server's current name with a warning: +# the underscored names earlier versions of this client used (they never matched +# a server route), and "brainregion", renamed "anatomicalregion". +_MODEL_ALIASES: dict = { + "brainregion": "anatomicalregion", + "project_membership_invitation": "projectmembershipinvitation", + "project_group_membership_invitation": "projectgroupmembershipinvitation", + "group_membership_invitation": "groupmembershipinvitation", + "group_membership_request": "groupmembershiprequest", +} + # Derived from _MODEL_TO_APP so there is exactly one source of truth. -ModelType = Enum("ModelType", {k: k for k in _MODEL_TO_APP}) # type: ignore[misc] +ModelType = Enum( # type: ignore[misc] + "ModelType", {k: k for k in [*_MODEL_TO_APP, *_MODEL_ALIASES]} +) _VALID_PORTALS = {"private", "public", "super"} def _resolve_model(model) -> str: - """Accept a plain string or a ModelType member and return the string value.""" + """Accept a plain string or a ModelType member and return the server's resource name.""" if isinstance(model, ModelType): - return model.value + model = model.value model = str(model) + if model in _MODEL_ALIASES: + warnings.warn( + f"Model name '{model}' is deprecated; use '{_MODEL_ALIASES[model]}'.", + FutureWarning, stacklevel=3, + ) + return _MODEL_ALIASES[model] if model not in _MODEL_TO_APP: raise ValueError( f"Unknown model '{model}'. Valid models are: " @@ -130,13 +197,32 @@ class BrainstemClient: BASE_URL = "https://www.brainstem.org/" DEFAULT_TIMEOUT: int = 30 # seconds; applied to all HTTP calls + MAX_PAGE_SIZE: int = 500 # the API returns at most this many records per page def __init__( self, token: str = None, headless: bool = False, url: str = None, + anonymous: bool = False, ) -> None: + """Create a client. + + Parameters + ---------- + token : API token. Defaults to ``$BRAINSTEM_API_TOKEN``, then the + cached token, then the browser device authorization flow. + headless : Print the verification URL and code instead of opening a browser. + url : Base URL of the server. Defaults to ``$BRAINSTEM_API_URL``, + then ``https://www.brainstem.org/``. + anonymous : Send no token and never start a login. Only the public + portal (``portal='public'``) is readable. + """ + if url is None: + url = os.environ.get("BRAINSTEM_API_URL") + if token is None: + token = os.environ.get("BRAINSTEM_API_TOKEN") + base = (url.rstrip("/") + "/") if url else self.BASE_URL self._base_url = base self._address = base + "api/" @@ -147,6 +233,10 @@ def __init__( self._session.mount("https://", HTTPAdapter(max_retries=_retry)) self._session.mount("http://", HTTPAdapter(max_retries=_retry)) + if anonymous: + self._token = None + return + if token: self._token = token else: @@ -268,11 +358,20 @@ def load(self, Supports ``.icontains``, ``.startswith``, ``.endswith``, ``.gt``, ``.gte``, ``.lt``, ``.lte``. sort : List of fields to sort by. Prefix with ``'-'`` for descending. - include : Related models to embed, e.g. ``['dataacquisition', 'behaviors']``. - limit : Max records per page (API maximum: 100). - offset : Number of records to skip (for pagination). + include : Related fields to sideload, e.g. ``['dataacquisition', 'behaviors']``. + Their records are returned as extra lists next to the main + one, and only records you may read are included. See ``save`` + before writing back a list field read this way. + limit : Records per page, 1 to 500 (default 100). Sent to the API as + ``per_page``. With *load_all*, a larger value is reduced to + 500, which returns the same records in more pages. + offset : Number of records to skip. The API pages with ``page`` and + ``per_page``, so *offset* must be a multiple of the page size + (*limit*, or 100 if *limit* is not set); it is sent as + ``page = offset // page_size + 1``. load_all : When ``True``, automatically follow pagination and return a - combined ``dict`` with all records merged under the model key. + combined ``dict`` with all records merged under the model key, + starting at the page selected by *offset*. When ``False`` (default), returns the raw ``Response``. """ model = _resolve_model(model) @@ -288,27 +387,34 @@ def load(self, params.setdefault("sort[]", []).append(field) for rel in (include or []): params.setdefault("include[]", []).append(f"{rel}.*") - if limit is not None: - params["limit"] = limit - if offset is not None: - params["offset"] = offset - if not load_all: - return self._session.get(url, params=params, timeout=self.DEFAULT_TIMEOUT) - - if id is not None: + if load_all and id is not None: raise ValueError("load_all=True cannot be used together with id.") - # The API paginates with page/per_page; translate limit/offset. - page_size = limit or 100 - if offset and offset % page_size: + # The API paginates with page/per_page (limit/offset are ignored by + # the server), so translate limit/offset into those parameters. + page_size = 100 if limit is None else limit + if load_all: + page_size = min(page_size, self.MAX_PAGE_SIZE) + if not id and not 1 <= page_size <= self.MAX_PAGE_SIZE: + raise ValueError( + f"limit ({limit}) must be between 1 and {self.MAX_PAGE_SIZE}, " + "the API's maximum page size." + ) + if not id and offset and offset % page_size: raise ValueError( - "With load_all=True, offset must be a multiple of limit (the page size)." + f"offset ({offset}) must be a multiple of limit (the page size, " + f"{page_size}): the API pages with page/per_page." ) - params.pop("limit", None) - params.pop("offset", None) - return self._load_all_pages(url, params, page_size, - start_page=(offset or 0) // page_size + 1) + start_page = (offset or 0) // page_size + 1 + + if load_all: + return self._load_all_pages(url, params, page_size, start_page=start_page) + + if not id and (limit is not None or offset is not None): + params["per_page"] = page_size + params["page"] = start_page + return self._session.get(url, params=params, timeout=self.DEFAULT_TIMEOUT) def _get_json(self, url: str, params: dict = None) -> dict: """GET *url* and return the decoded JSON, raising on HTTP errors.""" @@ -376,6 +482,14 @@ def save(self, When *id* is provided the record is **updated** (PATCH). When *id* is omitted a **new record** is created (POST). + .. warning:: + Sending a list field (``projects``, ``subjects``, ``behaviors``, ...) + replaces the whole list. A list read with ``include`` (including the + convenience loaders' default includes) holds only the records you may + read, so writing it back drops links to records you cannot see. Read + the record without ``include`` first, e.g. + ``client.load('session', id=uid).json()``, and change that list. + Parameters ---------- model : Model name string or ``ModelType`` member, e.g. ``'session'``. @@ -592,13 +706,13 @@ def load_project(self, portal="private", id: str = None, name : Filter by project name (case-insensitive contains). sessions : Filter by session UUID. subjects : Filter by subject UUID. - tags : Filter by tag. + tags : Filter by numeric tag id (the server does not filter by tag name). """ return self._convenience_load( "project", default_include=["sessions", "subjects", "collections", "cohorts"], - filter_map={"name": "name.icontains", "sessions": "sessions.id", - "subjects": "subjects.id", "tags": "tags"}, + filter_map={"name": "name.icontains", "sessions": "sessions", + "subjects": "subjects", "tags": "tags"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, name=name, sessions=sessions, subjects=subjects, tags=tags, @@ -618,13 +732,13 @@ def load_subject(self, portal="private", id: str = None, projects : Filter by project UUID. strain : Filter by strain UUID. sex : Filter by sex (``'M'``, ``'F'``, or ``'U'``). - tags : Filter by tag. + tags : Filter by numeric tag id (the server does not filter by tag name). """ return self._convenience_load( "subject", default_include=["procedures", "subjectlogs"], - filter_map={"name": "name.icontains", "projects": "projects.id", - "strain": "strain.id", "sex": "sex", "tags": "tags"}, + filter_map={"name": "name.icontains", "projects": "projects", + "strain": "strain", "sex": "sex", "tags": "tags"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, name=name, projects=projects, strain=strain, sex=sex, tags=tags, @@ -643,13 +757,13 @@ def load_session(self, portal="private", id: str = None, name : Filter by session name (case-insensitive contains). projects : Filter by project UUID. datastorage : Filter by data storage UUID. - tags : Filter by tag. + tags : Filter by numeric tag id (the server does not filter by tag name). """ return self._convenience_load( "session", default_include=["dataacquisition", "behaviors", "manipulations", "epochs"], - filter_map={"name": "name.icontains", "projects": "projects.id", - "datastorage": "datastorage.id", "tags": "tags"}, + filter_map={"name": "name.icontains", "projects": "projects", + "datastorage": "datastorage", "tags": "tags"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, name=name, projects=projects, datastorage=datastorage, tags=tags, @@ -665,7 +779,7 @@ def load_collection(self, portal="private", id: str = None, Parameters ---------- name : Filter by collection name (case-insensitive contains). - tags : Filter by tag. + tags : Filter by numeric tag id (the server does not filter by tag name). """ return self._convenience_load( "collection", @@ -686,7 +800,7 @@ def load_cohort(self, portal="private", id: str = None, Parameters ---------- name : Filter by cohort name (case-insensitive contains). - tags : Filter by tag. + tags : Filter by numeric tag id (the server does not filter by tag name). """ return self._convenience_load( "cohort", @@ -698,7 +812,7 @@ def load_cohort(self, portal="private", id: str = None, ) def load_behavior(self, portal="private", id: str = None, - session: str = None, tags: str = None, + session: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -707,19 +821,18 @@ def load_behavior(self, portal="private", id: str = None, Parameters ---------- session : Filter by session UUID. - tags : Filter by tag. """ return self._convenience_load( "behavior", default_include=[], - filter_map={"session": "session.id", "tags": "tags"}, + filter_map={"session": "session"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - session=session, tags=tags, + session=session, ) def load_dataacquisition(self, portal="private", id: str = None, - session: str = None, tags: str = None, + session: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -728,19 +841,18 @@ def load_dataacquisition(self, portal="private", id: str = None, Parameters ---------- session : Filter by session UUID. - tags : Filter by tag. """ return self._convenience_load( "dataacquisition", default_include=[], - filter_map={"session": "session.id", "tags": "tags"}, + filter_map={"session": "session"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - session=session, tags=tags, + session=session, ) def load_manipulation(self, portal="private", id: str = None, - session: str = None, tags: str = None, + session: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -749,19 +861,18 @@ def load_manipulation(self, portal="private", id: str = None, Parameters ---------- session : Filter by session UUID. - tags : Filter by tag. """ return self._convenience_load( "manipulation", default_include=[], - filter_map={"session": "session.id", "tags": "tags"}, + filter_map={"session": "session"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - session=session, tags=tags, + session=session, ) def load_procedure(self, portal="private", id: str = None, - subject: str = None, tags: str = None, + subject: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -770,19 +881,18 @@ def load_procedure(self, portal="private", id: str = None, Parameters ---------- subject : Filter by subject UUID. - tags : Filter by tag. """ return self._convenience_load( "procedure", default_include=[], - filter_map={"subject": "subject.id", "tags": "tags"}, + filter_map={"subject": "subject"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - subject=subject, tags=tags, + subject=subject, ) def load_procedurelog(self, portal="private", id: str = None, - procedure: str = None, tags: str = None, + procedure: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -791,19 +901,18 @@ def load_procedurelog(self, portal="private", id: str = None, Parameters ---------- procedure : Filter by procedure UUID. - tags : Filter by tag. """ return self._convenience_load( "procedurelog", default_include=[], - filter_map={"procedure": "procedure.id", "tags": "tags"}, + filter_map={"procedure": "procedure"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - procedure=procedure, tags=tags, + procedure=procedure, ) def load_subjectlog(self, portal="private", id: str = None, - subject: str = None, tags: str = None, + subject: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -812,19 +921,18 @@ def load_subjectlog(self, portal="private", id: str = None, Parameters ---------- subject : Filter by subject UUID. - tags : Filter by tag. """ return self._convenience_load( "subjectlog", default_include=[], - filter_map={"subject": "subject.id", "tags": "tags"}, + filter_map={"subject": "subject"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - subject=subject, tags=tags, + subject=subject, ) def load_equipment(self, portal="private", id: str = None, - name: str = None, setup: str = None, tags: str = None, + name: str = None, setup: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -834,19 +942,18 @@ def load_equipment(self, portal="private", id: str = None, ---------- name : Filter by equipment name (case-insensitive contains). setup : Filter by setup UUID. - tags : Filter by tag. """ return self._convenience_load( "equipment", default_include=[], - filter_map={"name": "name.icontains", "setup": "setup.id", "tags": "tags"}, + filter_map={"name": "name.icontains", "setup": "setup"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, setup=setup, tags=tags, + name=name, setup=setup, ) def load_consumablestock(self, portal="private", id: str = None, - tags: str = None, + inventory: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -854,19 +961,19 @@ def load_consumablestock(self, portal="private", id: str = None, Parameters ---------- - tags : Filter by tag. + inventory : Filter by inventory UUID. """ return self._convenience_load( "consumablestock", default_include=[], - filter_map={"tags": "tags"}, + filter_map={"inventory": "inventory"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - tags=tags, + inventory=inventory, ) def load_behavioralassay(self, portal="private", id: str = None, - name: str = None, tags: str = None, + name: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -875,19 +982,18 @@ def load_behavioralassay(self, portal="private", id: str = None, Parameters ---------- name : Filter by name (case-insensitive contains). - tags : Filter by tag. """ return self._convenience_load( "behavioralassay", default_include=[], - filter_map={"name": "name.icontains", "tags": "tags"}, + filter_map={"name": "name.icontains"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, tags=tags, + name=name, ) def load_datastorage(self, portal="private", id: str = None, - name: str = None, tags: str = None, + name: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -896,19 +1002,18 @@ def load_datastorage(self, portal="private", id: str = None, Parameters ---------- name : Filter by name (case-insensitive contains). - tags : Filter by tag. """ return self._convenience_load( "datastorage", default_include=[], - filter_map={"name": "name.icontains", "tags": "tags"}, + filter_map={"name": "name.icontains"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, tags=tags, + name=name, ) def load_setup(self, portal="private", id: str = None, - name: str = None, tags: str = None, + name: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -917,19 +1022,18 @@ def load_setup(self, portal="private", id: str = None, Parameters ---------- name : Filter by name (case-insensitive contains). - tags : Filter by tag. """ return self._convenience_load( "setup", default_include=[], - filter_map={"name": "name.icontains", "tags": "tags"}, + filter_map={"name": "name.icontains"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, tags=tags, + name=name, ) def load_hardwaredevice(self, portal="private", id: str = None, - name: str = None, tags: str = None, + name: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -938,83 +1042,128 @@ def load_hardwaredevice(self, portal="private", id: str = None, Parameters ---------- name : Filter by name (case-insensitive contains). - tags : Filter by tag. """ return self._convenience_load( "hardwaredevice", default_include=[], - filter_map={"name": "name.icontains", "tags": "tags"}, + filter_map={"name": "name.icontains"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, tags=tags, + name=name, ) def load_brainregion(self, portal="private", id: str = None, - name: str = None, tags: str = None, + name: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): - """Load brain region record(s). + """Deprecated: use ``load_anatomicalregion``, which this calls. + + Brain regions are ``anatomicalregion`` records; the response holds them + under ``anatomical_regions``. Parameters ---------- name : Filter by name (case-insensitive contains). - tags : Filter by tag. + """ + warnings.warn( + "load_brainregion() is deprecated; use load_anatomicalregion().", + FutureWarning, stacklevel=2, + ) + return self.load_anatomicalregion( + portal=portal, id=id, name=name, filters=filters, sort=sort, + include=include, limit=limit, offset=offset, load_all=load_all, + ) + + def load_anatomicalregion(self, portal="private", id: str = None, + name: str = None, acronym: str = None, + reference: str = None, + filters: dict = None, sort: list = None, + include: list = None, limit: int = None, + offset: int = None, load_all: bool = False): + """Load anatomical (brain) region record(s). + + Parameters + ---------- + name : Filter by name (case-insensitive contains). + acronym : Filter by acronym (case-insensitive exact match), e.g. ``'CA1'``. + reference : Filter by anatomical reference (atlas) UUID. """ return self._convenience_load( - "brainregion", + "anatomicalregion", default_include=[], - filter_map={"name": "name.icontains", "tags": "tags"}, + filter_map={"name": "name.icontains", "acronym": "acronym.iexact", + "reference": "reference"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, tags=tags, + name=name, acronym=acronym, reference=reference, ) + def load_anatomicalregion_descendants(self, id: str, portal="private") -> Response: + """Load every region below anatomical region *id* in its atlas. + + Returns the raw ``Response``; its JSON is + ``{"anatomical_regions": [{"id", "name", "acronym", "parent_acronym", + "is_obsolete", "depth"}, ...]}``, ordered by depth (1 = direct child). + Atlases without a hierarchy return an empty list. + """ + if not id: + raise ValueError("'id' is required to load the descendants of a region.") + url = self._build_url(_resolve_portal(portal), "taxonomies", "anatomicalregion", + id, "descendants/") + return self._session.get(url, timeout=self.DEFAULT_TIMEOUT) + def load_species(self, portal="private", id: str = None, - name: str = None, tags: str = None, + name: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, - offset: int = None, load_all: bool = False): + offset: int = None, load_all: bool = False, + popular_name: str = None): """Load species record(s). Parameters ---------- - name : Filter by name (case-insensitive contains). - tags : Filter by tag. + name : Filter by scientific name (case-insensitive contains). + popular_name : Filter by common name (case-insensitive contains), e.g. + ``'mouse'``. Needs a server with the specimen release. """ return self._convenience_load( "species", default_include=[], - filter_map={"name": "name.icontains", "tags": "tags"}, + filter_map={"name": "name.icontains", + "popular_name": "popular_name.icontains"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, tags=tags, + name=name, popular_name=popular_name, ) def load_strain(self, portal="private", id: str = None, - name: str = None, species: str = None, tags: str = None, + name: str = None, species: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, - offset: int = None, load_all: bool = False): + offset: int = None, load_all: bool = False, + popular_name: str = None): """Load strain record(s). Parameters ---------- - name : Filter by name (case-insensitive contains). - species : Filter by species UUID. - tags : Filter by tag. + name : Filter by name (case-insensitive contains). + species : Filter by species UUID. + popular_name : Filter by common name (case-insensitive contains). + Needs a server with the specimen release. """ return self._convenience_load( "strain", default_include=[], - filter_map={"name": "name.icontains", "species": "species.id", "tags": "tags"}, + filter_map={"name": "name.icontains", "species": "species", + "popular_name": "popular_name.icontains"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, species=species, tags=tags, + name=name, species=species, popular_name=popular_name, ) def load_publication(self, portal="private", id: str = None, - name: str = None, tags: str = None, + name: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -1023,19 +1172,18 @@ def load_publication(self, portal="private", id: str = None, Parameters ---------- name : Filter by name (case-insensitive contains). - tags : Filter by tag. """ return self._convenience_load( "publication", default_include=[], - filter_map={"name": "name.icontains", "tags": "tags"}, + filter_map={"name": "name.icontains"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, tags=tags, + name=name, ) def load_laboratory(self, portal="private", id: str = None, - name: str = None, tags: str = None, + name: str = None, filters: dict = None, sort: list = None, include: list = None, limit: int = None, offset: int = None, load_all: bool = False): @@ -1044,13 +1192,12 @@ def load_laboratory(self, portal="private", id: str = None, Parameters ---------- name : Filter by name (case-insensitive contains). - tags : Filter by tag. """ return self._convenience_load( "laboratory", default_include=[], - filter_map={"name": "name.icontains", "tags": "tags"}, + filter_map={"name": "name.icontains"}, portal=portal, id=id, filters=filters, sort=sort, include=include, limit=limit, offset=offset, load_all=load_all, - name=name, tags=tags, + name=name, ) diff --git a/brainstem_api_tools/cli.py b/brainstem_api_tools/cli.py index 043b5d1..6eb4d8c 100644 --- a/brainstem_api_tools/cli.py +++ b/brainstem_api_tools/cli.py @@ -10,6 +10,8 @@ brainstem load session --portal public --filters name.icontains=rat --sort -name brainstem load session --id brainstem load session --limit 20 --offset 40 + brainstem load session --per-page 20 --page 3 + brainstem load session --all brainstem save session --data '{"name":"New","projects":[""]}' brainstem save session --id --data '{"description":"updated"}' brainstem delete session --id @@ -20,32 +22,50 @@ import os import sys -from .brainstem_api_client import BrainstemClient, _MODEL_TO_APP, _TOKEN_FILE +import requests +from .brainstem_api_client import ( + AuthenticationError, BrainstemClient, _MODEL_TO_APP, _TOKEN_FILE, +) + + +def _common_flags(suppress_defaults: bool = False) -> argparse.ArgumentParser: + """Shared flags available before and after the subcommand. + + The subcommand copies use ``argparse.SUPPRESS`` defaults so they do not + overwrite a flag given before the subcommand (``brainstem --url X load``). + """ + def default(value): + return argparse.SUPPRESS if suppress_defaults else value -def _build_parser() -> argparse.ArgumentParser: - # Shared flags available on every subcommand common = argparse.ArgumentParser(add_help=False) common.add_argument( "--token", - default=os.environ.get("BRAINSTEM_API_TOKEN"), + default=default(os.environ.get("BRAINSTEM_API_TOKEN")), help="API token. Defaults to $BRAINSTEM_API_TOKEN or the cached token.", ) common.add_argument( "--headless", action="store_true", + default=default(False), help="Print verification URL + code instead of opening a browser.", ) common.add_argument( "--url", - default=None, - help="Base URL of the BrainSTEM server (default: https://www.brainstem.org/).", + default=default(os.environ.get("BRAINSTEM_API_URL")), + help="Base URL of the BrainSTEM server. Defaults to $BRAINSTEM_API_URL " + "or https://www.brainstem.org/.", ) + return common + + +def _build_parser() -> argparse.ArgumentParser: + common = _common_flags(suppress_defaults=True) parser = argparse.ArgumentParser( prog="brainstem", description="BrainSTEM command-line API client.", - parents=[common], + parents=[_common_flags()], ) sub = parser.add_subparsers(dest="command", required=True) @@ -87,8 +107,17 @@ def _build_parser() -> argparse.ArgumentParser: metavar="RELATION", help="Related models to embed.", ) - p_load.add_argument("--limit", type=int, help="Max records (API max: 100).") - p_load.add_argument("--offset", type=int, help="Records to skip (pagination).") + p_load.add_argument("--limit", type=int, + help="Records per page (sent as per_page; default 100).") + p_load.add_argument("--offset", type=int, + help="Records to skip; must be a multiple of the page size.") + p_load.add_argument("--per-page", type=int, help="Records per page (default 100).") + p_load.add_argument("--page", type=int, help="Page number, starting at 1.") + p_load.add_argument( + "--all", + action="store_true", + help="Follow pagination and print all records merged into one JSON object.", + ) # ---- save ------------------------------------------------------- p_save = sub.add_parser("save", parents=[common], help="Create or update a record.") @@ -129,8 +158,11 @@ def main(): ) if args.command == "login": - print(f"Token: {client._token}") - print(f"Cached at: {_TOKEN_FILE}") + if args.token: + print("Logged in with the token from --token / $BRAINSTEM_API_TOKEN " + "(not cached).") + else: + print(f"Logged in. Token cached at {_TOKEN_FILE}") return if args.command == "load": @@ -141,16 +173,39 @@ def main(): k, v = expr.split("=", 1) filters[k] = v - resp = client.load( - args.model, + limit, offset = args.limit, args.offset + if args.per_page is not None or args.page is not None: + if limit is not None or offset is not None: + parser.error("Use either --per-page/--page or --limit/--offset, not both.") + if (args.per_page is not None and args.per_page < 1) or \ + (args.page is not None and args.page < 1): + parser.error("--per-page and --page must be at least 1.") + limit = args.per_page + if args.page is not None: + offset = (args.page - 1) * (args.per_page or 100) + + load_kwargs = dict( portal=args.portal, id=args.id, filters=filters or None, sort=args.sort, include=args.include, - limit=args.limit, - offset=args.offset, + limit=limit, + offset=offset, ) + if args.all: + try: + result = client.load(args.model, load_all=True, **load_kwargs) + except (ValueError, AuthenticationError, requests.HTTPError) as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + print(json.dumps(result, indent=2)) + return + + try: + resp = client.load(args.model, **load_kwargs) + except ValueError as exc: + parser.error(str(exc)) elif args.command == "save": try: diff --git a/tests/test_client.py b/tests/test_client.py index ae04d99..196cfe5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -15,6 +15,7 @@ from brainstem_api_tools.brainstem_api_client import ( BrainstemClient, AuthenticationError, + ModelType, ExportError, _MODEL_TO_APP, _resolve_model, @@ -29,6 +30,13 @@ TOKEN = "test-token-abc123" +@pytest.fixture(autouse=True) +def _clear_brainstem_env(monkeypatch): + """Keep the developer's BRAINSTEM_* environment out of the tests.""" + monkeypatch.delenv("BRAINSTEM_API_TOKEN", raising=False) + monkeypatch.delenv("BRAINSTEM_API_URL", raising=False) + + def make_client(token=TOKEN): """Return a BrainstemClient with a pre-set token (no auth flow).""" return BrainstemClient(token=token) @@ -75,6 +83,16 @@ def test_auth_header_on_session(self): client = make_client() assert client._session.headers["Authorization"] == f"Bearer {TOKEN}" + def test_anonymous_sends_no_token_and_skips_login(self, monkeypatch): + monkeypatch.setenv("BRAINSTEM_API_TOKEN", "env-token") + with patch.object(BrainstemClient, "_load_cached_token") as cached, \ + patch.object(BrainstemClient, "_device_auth_flow") as flow: + client = BrainstemClient(anonymous=True) + cached.assert_not_called() + flow.assert_not_called() + assert client._token is None + assert "Authorization" not in client._session.headers + def test_cached_token_used_on_init(self, tmp_path, monkeypatch): token_file = tmp_path / "token" token_file.write_text("cached-token") @@ -96,6 +114,34 @@ def test_device_flow_triggered_when_no_cache(self, tmp_path, monkeypatch): mock_save.assert_called_once_with("new-token") assert client._token == "new-token" + def test_token_from_env(self, tmp_path, monkeypatch): + token_file = tmp_path / "token" + token_file.write_text("cached-token") + monkeypatch.setattr( + "brainstem_api_tools.brainstem_api_client._TOKEN_FILE", token_file + ) + monkeypatch.setenv("BRAINSTEM_API_TOKEN", "env-token") + client = BrainstemClient() + assert client._token == "env-token" + assert client._session.headers["Authorization"] == "Bearer env-token" + + def test_explicit_token_beats_env(self, monkeypatch): + monkeypatch.setenv("BRAINSTEM_API_TOKEN", "env-token") + assert make_client()._token == TOKEN + + def test_url_from_env(self, monkeypatch): + monkeypatch.setenv("BRAINSTEM_API_URL", "http://127.0.0.1:8000") + client = make_client() + assert client._address == "http://127.0.0.1:8000/api/" + + def test_explicit_url_beats_env(self, monkeypatch): + monkeypatch.setenv("BRAINSTEM_API_URL", "http://127.0.0.1:8000") + client = BrainstemClient(token=TOKEN, url="https://example.org") + assert client._address == "https://example.org/api/" + + def test_default_url_without_env(self): + assert make_client()._address == "https://www.brainstem.org/api/" + # --------------------------------------------------------------------------- # load() @@ -144,10 +190,45 @@ def test_load_include_appends_wildcard(self): def test_load_pagination_params(self): self._mock_get() - self.client.load("session", limit=50, offset=20) + self.client.load("session", limit=20, offset=40) params = self.client._session.get.call_args[1]["params"] - assert params["limit"] == 50 - assert params["offset"] == 20 + assert params["per_page"] == 20 + assert params["page"] == 3 + assert "limit" not in params and "offset" not in params + + def test_load_limit_only_sets_per_page(self): + self._mock_get() + self.client.load("session", limit=50) + params = self.client._session.get.call_args[1]["params"] + assert params["per_page"] == 50 + assert params["page"] == 1 + + def test_load_offset_without_limit_uses_page_size_100(self): + self._mock_get() + self.client.load("session", offset=200) + params = self.client._session.get.call_args[1]["params"] + assert params["per_page"] == 100 + assert params["page"] == 3 + + def test_load_offset_not_multiple_of_page_size_raises(self): + self._mock_get() + with pytest.raises(ValueError, match="multiple of limit"): + self.client.load("session", limit=50, offset=20) + with pytest.raises(ValueError, match="page size, 100"): + self.client.load("session", offset=30) + self.client._session.get.assert_not_called() + + def test_load_without_paging_sends_no_page_params(self): + self._mock_get() + self.client.load("session") + params = self.client._session.get.call_args[1]["params"] + assert "per_page" not in params and "page" not in params + + def test_load_with_id_ignores_paging(self): + self._mock_get() + self.client.load("session", id="00000000-0000-0000-0000-000000000001", + limit=10, offset=5) + assert self.client._session.get.call_args[1]["params"] == {} def test_load_public_portal(self): self._mock_get() @@ -159,16 +240,83 @@ def test_load_invalid_model_raises(self): with pytest.raises(ValueError, match="Unknown model"): self.client.load("notamodel") + @pytest.mark.parametrize("model,expected_app", [ + *[(m, "stem") for m in [ + "specimenset", "specimen", "epoch", "sessiondatastorage", + "collectionsession", "cohortsubject"]], + *[(m, "modules") for m in [ + "experimentalinterface", "experimentalinterfacetemplate", "materialusage", + "materialusagetemplate", "specimenprocedure", "specimenproceduretemplate", + "specimenlog", "dataacquisitioninterface", "manipulationinterface", + "dataacquisitionprocedure", "proceduretemplate", "dataacquisitiontemplate", + "manipulationtemplate", "procedureepisode", "procedureepisodetemplate", + "episodepersonnel"]], + *[(m, "taxonomies") for m in [ + "anatomicalregion", "anatomicalreference", "coordinatetransform"]], + ("pendinginvitation", "users"), + ]) + def test_new_models_routed(self, model, expected_app): + assert _MODEL_TO_APP[model] == expected_app + self._mock_get() + self.client.load(model) + assert f"/api/private/{expected_app}/{model}/" in self.client._session.get.call_args[0][0] + def test_load_invalid_portal_raises(self): with pytest.raises(ValueError, match="Unknown portal"): self.client.load("session", portal="badportal") + @pytest.mark.parametrize("old,new,app", [ + ("project_membership_invitation", "projectmembershipinvitation", "stem"), + ("project_group_membership_invitation", "projectgroupmembershipinvitation", "stem"), + ("group_membership_invitation", "groupmembershipinvitation", "users"), + ("group_membership_request", "groupmembershiprequest", "users"), + ]) + def test_old_underscored_names_resolve_with_warning(self, old, new, app): + self._mock_get() + with pytest.warns(FutureWarning, match=f"use '{new}'"): + self.client.load(old) + assert f"/api/private/{app}/{new}/" in self.client._session.get.call_args[0][0] + with pytest.warns(FutureWarning): + self.client.load(ModelType[old]) + + @pytest.mark.parametrize("model,app", [ + ("admingroup", "users"), + ("speciesapproval", "taxonomies"), + ("supplierapproval", "resources"), + ("publicationapproval", "dissemination"), + ("setupmembershipinvitation", "personal_attributes"), + ("datastoragegroupmembershipinvitation", "personal_attributes"), + ]) + def test_approval_and_invitation_resources_routed(self, model, app): + self._mock_get() + self.client.load(model) + assert f"/api/private/{app}/{model}/" in self.client._session.get.call_args[0][0] + + def test_brainregion_resolves_to_anatomicalregion(self): + self._mock_get() + with pytest.warns(FutureWarning, match="use 'anatomicalregion'"): + self.client.load("brainregion") + assert "/taxonomies/anatomicalregion/" in self.client._session.get.call_args[0][0] + + def test_limit_above_page_size_maximum_raises(self): + self._mock_get() + with pytest.raises(ValueError, match="between 1 and 500"): + self.client.load("session", limit=501) + with pytest.raises(ValueError, match="between 1 and 500"): + self.client.load("session", limit=0) + self.client._session.get.assert_not_called() + + def test_limit_at_page_size_maximum(self): + self._mock_get() + self.client.load("session", limit=500) + assert self.client._session.get.call_args[1]["params"]["per_page"] == 500 + def test_model_to_app_routing(self): """Spot-check a few app assignments.""" cases = [ ("session", "stem"), ("breeding", "stem"), - ("project_membership_invitation", "stem"), + ("projectmembershipinvitation", "stem"), ("procedure", "modules"), ("behavioralassay", "personal_attributes"), ("license", "personal_attributes"), @@ -177,11 +325,14 @@ def test_model_to_app_routing(self): ("behavioralparadigm", "taxonomies"), ("behavioralcategory", "taxonomies"), ("regulatoryauthority", "taxonomies"), - ("brainregion", "taxonomies"), + ("anatomicalregion", "taxonomies"), + ("specimenset", "stem"), + ("experimentalinterface", "modules"), + ("pendinginvitation", "users"), ("publication", "dissemination"), ("laboratory", "users"), - ("group_membership_invitation", "users"), - ("group_membership_request", "users"), + ("groupmembershipinvitation", "users"), + ("groupmembershiprequest", "users"), ] for model, expected_app in cases: self._mock_get() @@ -361,6 +512,11 @@ def test_offset_translated_to_page(self): self.client.load("session", limit=2, offset=2, load_all=True) assert self.client._session.get.call_args[1]["params"]["page"] == 2 + def test_load_all_reduces_page_size_to_maximum(self): + self._mock_pages([{"sessions": [{"id": "1"}], "meta": {"total_pages": 1}}]) + self.client.load("session", limit=1000, load_all=True) + assert self.client._session.get.call_args[1]["params"]["per_page"] == 500 + def test_offset_not_multiple_of_limit_raises(self): with pytest.raises(ValueError, match="multiple of limit"): self.client.load("session", limit=10, offset=5, load_all=True) @@ -589,7 +745,7 @@ def test_load_subject_sex_filter(self): def test_load_subject_strain_filter(self): uuid = "aaaaaaaa-0000-0000-0000-000000000000" self.client.load_subject(strain=uuid) - assert self._get_params()["filter{strain.id}"] == uuid + assert self._get_params()["filter{strain}"] == uuid def test_load_session_default_include(self): self.client.load_session() @@ -601,7 +757,7 @@ def test_load_session_default_include(self): def test_load_session_project_filter(self): uuid = "bbbbbbbb-0000-0000-0000-000000000000" self.client.load_session(projects=uuid) - assert self._get_params()["filter{projects.id}"] == uuid + assert self._get_params()["filter{projects}"] == uuid def test_load_collection_default_include(self): self.client.load_collection() @@ -614,22 +770,46 @@ def test_load_cohort_default_include(self): def test_load_behavior_session_filter(self): uuid = "cccccccc-0000-0000-0000-000000000000" self.client.load_behavior(session=uuid) - assert self._get_params()["filter{session.id}"] == uuid + assert self._get_params()["filter{session}"] == uuid def test_load_dataacquisition_session_filter(self): uuid = "dddddddd-0000-0000-0000-000000000000" self.client.load_dataacquisition(session=uuid) - assert self._get_params()["filter{session.id}"] == uuid + assert self._get_params()["filter{session}"] == uuid def test_load_manipulation_session_filter(self): uuid = "eeeeeeee-0000-0000-0000-000000000000" self.client.load_manipulation(session=uuid) - assert self._get_params()["filter{session.id}"] == uuid + assert self._get_params()["filter{session}"] == uuid def test_load_procedure_subject_filter(self): uuid = "ffffffff-0000-0000-0000-000000000000" self.client.load_procedure(subject=uuid) - assert self._get_params()["filter{subject.id}"] == uuid + assert self._get_params()["filter{subject}"] == uuid + + @pytest.mark.parametrize("loader,kwarg", [ + ("load_procedure", "subject"), + ("load_behavior", "session"), + ("load_dataacquisition", "session"), + ("load_manipulation", "session"), + ("load_procedurelog", "procedure"), + ("load_subjectlog", "subject"), + ("load_equipment", "setup"), + ("load_strain", "species"), + ]) + def test_non_nestable_relation_filters_use_bare_field(self, loader, kwarg): + # The server rejects filter{.id} for plain PK relations. + uuid = "12345678-0000-0000-0000-000000000000" + getattr(self.client, loader)(**{kwarg: uuid}) + params = self._get_params() + assert params[f"filter{{{kwarg}}}"] == uuid + assert f"filter{{{kwarg}.id}}" not in params + + def test_convenience_loader_paging_translated(self): + self.client.load_session(limit=25, offset=50) + params = self._get_params() + assert params["per_page"] == 25 + assert params["page"] == 3 def test_custom_include_overrides_default(self): self.client.load_session(include=["behaviors"]) @@ -673,6 +853,35 @@ def test_logout_no_token_file(self, tmp_path, capsys): assert "No cached token found" in capsys.readouterr().out +# --------------------------------------------------------------------------- +# CLI login +# --------------------------------------------------------------------------- + +class TestCLILogin: + def _run_login(self, argv, token): + import brainstem_api_tools.cli as cli_module + client = MagicMock() + client._token = token + with patch("brainstem_api_tools.cli.BrainstemClient", return_value=client) as cls, \ + patch("sys.argv", argv): + cli_module.main() + return cls + + def test_login_does_not_print_token(self, capsys): + import brainstem_api_tools.cli as cli_module + self._run_login(["brainstem", "login"], "secret-device-token") + out = capsys.readouterr().out + assert "secret-device-token" not in out + assert "Logged in" in out + assert str(cli_module._TOKEN_FILE) in out + + def test_login_with_explicit_token_does_not_print_it(self, capsys): + self._run_login(["brainstem", "login", "--token", TOKEN], TOKEN) + out = capsys.readouterr().out + assert TOKEN not in out + assert "Logged in" in out + + # --------------------------------------------------------------------------- # Timeouts # --------------------------------------------------------------------------- @@ -818,19 +1027,19 @@ def _get_url(self): def test_load_procedurelog_procedure_filter(self): uuid = "11111111-0000-0000-0000-000000000000" self.client.load_procedurelog(procedure=uuid) - assert self._get_params()["filter{procedure.id}"] == uuid + assert self._get_params()["filter{procedure}"] == uuid assert "/modules/procedurelog/" in self._get_url() def test_load_subjectlog_subject_filter(self): uuid = "22222222-0000-0000-0000-000000000000" self.client.load_subjectlog(subject=uuid) - assert self._get_params()["filter{subject.id}"] == uuid + assert self._get_params()["filter{subject}"] == uuid assert "/modules/subjectlog/" in self._get_url() def test_load_equipment_setup_filter(self): uuid = "33333333-0000-0000-0000-000000000000" self.client.load_equipment(setup=uuid) - assert self._get_params()["filter{setup.id}"] == uuid + assert self._get_params()["filter{setup}"] == uuid assert "/modules/equipment/" in self._get_url() def test_load_equipment_name_filter(self): @@ -858,10 +1067,11 @@ def test_load_hardwaredevice_url(self): self.client.load_hardwaredevice() assert "/resources/hardwaredevice/" in self._get_url() - def test_load_brainregion_name_filter(self): - self.client.load_brainregion(name="CA1") + def test_load_brainregion_is_deprecated_alias(self): + with pytest.warns(FutureWarning, match="load_anatomicalregion"): + self.client.load_brainregion(name="CA1") assert self._get_params()["filter{name.icontains}"] == "CA1" - assert "/taxonomies/brainregion/" in self._get_url() + assert "/taxonomies/anatomicalregion/" in self._get_url() def test_load_species_url(self): self.client.load_species() @@ -870,9 +1080,59 @@ def test_load_species_url(self): def test_load_strain_species_filter(self): uuid = "44444444-0000-0000-0000-000000000000" self.client.load_strain(species=uuid) - assert self._get_params()["filter{species.id}"] == uuid + assert self._get_params()["filter{species}"] == uuid assert "/taxonomies/strain/" in self._get_url() + def test_load_consumablestock_inventory_filter(self): + uuid = "77777777-0000-0000-0000-000000000000" + self.client.load_consumablestock(inventory=uuid) + assert self._get_params()["filter{inventory}"] == uuid + assert "/modules/consumablestock/" in self._get_url() + + @pytest.mark.parametrize("loader", [ + "load_behavior", "load_dataacquisition", "load_manipulation", "load_procedure", + "load_procedurelog", "load_subjectlog", "load_equipment", "load_consumablestock", + "load_behavioralassay", "load_datastorage", "load_setup", "load_hardwaredevice", + "load_brainregion", "load_species", "load_strain", "load_publication", "load_laboratory", + ]) + def test_untagged_models_have_no_tags_filter(self, loader): + # The server answers 400 "Invalid filter field: tags" for these models. + with pytest.raises(TypeError): + getattr(self.client, loader)(tags="x") + + def test_load_species_popular_name_filter(self): + self.client.load_species(popular_name="mouse") + assert self._get_params()["filter{popular_name.icontains}"] == "mouse" + assert "/taxonomies/species/" in self._get_url() + + def test_load_strain_popular_name_filter(self): + self.client.load_strain(popular_name="black 6") + assert self._get_params()["filter{popular_name.icontains}"] == "black 6" + + def test_popular_name_not_sent_unless_given(self): + self.client.load_species(name="Mus") + assert "filter{popular_name.icontains}" not in self._get_params() + + def test_load_anatomicalregion_filters(self): + ref = "55555555-0000-0000-0000-000000000000" + self.client.load_anatomicalregion(acronym="CA1", name="field", reference=ref) + params = self._get_params() + assert params["filter{acronym.iexact}"] == "CA1" + assert params["filter{name.icontains}"] == "field" + assert params["filter{reference}"] == ref + assert "/taxonomies/anatomicalregion/" in self._get_url() + + def test_load_anatomicalregion_descendants_url(self): + uuid = "66666666-0000-0000-0000-000000000000" + self.client.load_anatomicalregion_descendants(uuid, portal="public") + assert self._get_url() == ( + f"https://www.brainstem.org/api/public/taxonomies/anatomicalregion/{uuid}/descendants/" + ) + + def test_load_anatomicalregion_descendants_requires_id(self): + with pytest.raises(ValueError, match="'id' is required"): + self.client.load_anatomicalregion_descendants(None) + def test_load_publication_url(self): self.client.load_publication() assert "/dissemination/publication/" in self._get_url() @@ -947,3 +1207,89 @@ def test_cli_invalid_filter_exits(self): with pytest.raises(SystemExit): cli_module.main() + def _load_kwargs(self, argv): + client = MagicMock() + client.load.return_value = mock_response(200, {"sessions": []}) + self._run_cli(["brainstem", "load", "session", *argv], client) + return client.load.call_args[1] + + def test_cli_limit_offset_passed_through(self): + kwargs = self._load_kwargs(["--limit", "20", "--offset", "40"]) + assert kwargs["limit"] == 20 and kwargs["offset"] == 40 + + def test_cli_per_page_and_page(self): + kwargs = self._load_kwargs(["--per-page", "20", "--page", "3"]) + assert kwargs["limit"] == 20 + assert kwargs["offset"] == 40 + assert "load_all" not in kwargs + + def test_cli_page_without_per_page_uses_100(self): + kwargs = self._load_kwargs(["--page", "2"]) + assert kwargs["limit"] is None + assert kwargs["offset"] == 100 + + def test_cli_per_page_without_page(self): + kwargs = self._load_kwargs(["--per-page", "10"]) + assert kwargs["limit"] == 10 + assert kwargs["offset"] is None + + def test_cli_page_and_limit_conflict_exits(self): + with pytest.raises(SystemExit): + self._load_kwargs(["--page", "2", "--limit", "10"]) + + def test_cli_page_below_one_exits(self): + with pytest.raises(SystemExit): + self._load_kwargs(["--page", "0"]) + + def test_cli_all_prints_merged_json(self, capsys): + merged = {"sessions": [{"id": "1"}, {"id": "2"}], "meta": {"total_pages": 2}} + client = MagicMock() + client.load.return_value = merged + self._run_cli(["brainstem", "load", "session", "--all", "--per-page", "50"], client) + kwargs = client.load.call_args[1] + assert kwargs["load_all"] is True + assert kwargs["limit"] == 50 + assert json.loads(capsys.readouterr().out) == merged + + def test_cli_all_error_exits_nonzero(self, capsys): + client = MagicMock() + client.load.side_effect = AuthenticationError("API token is invalid or expired.") + with pytest.raises(SystemExit) as exc: + self._run_cli(["brainstem", "load", "session", "--all"], client) + assert exc.value.code == 1 + assert "invalid or expired" in capsys.readouterr().err + + def test_cli_global_flags_before_subcommand_kept(self): + client = MagicMock() + client.load.return_value = mock_response(200, {}) + with patch("brainstem_api_tools.cli.BrainstemClient", return_value=client) as cls, \ + patch("sys.argv", ["brainstem", "--token", TOKEN, "--url", "http://x/", + "load", "session"]): + import brainstem_api_tools.cli as cli_module + cli_module.main() + assert cls.call_args[1]["token"] == TOKEN + assert cls.call_args[1]["url"] == "http://x/" + + def test_cli_url_defaults_to_env(self, monkeypatch): + monkeypatch.setenv("BRAINSTEM_API_URL", "http://127.0.0.1:8000/") + monkeypatch.setenv("BRAINSTEM_API_TOKEN", "env-token") + client = MagicMock() + client.load.return_value = mock_response(200, {}) + with patch("brainstem_api_tools.cli.BrainstemClient", return_value=client) as cls, \ + patch("sys.argv", ["brainstem", "load", "session"]): + import brainstem_api_tools.cli as cli_module + cli_module.main() + assert cls.call_args[1]["url"] == "http://127.0.0.1:8000/" + assert cls.call_args[1]["token"] == "env-token" + + def test_cli_offset_not_multiple_of_page_size_exits(self, capsys): + import brainstem_api_tools.cli as cli_module + client = BrainstemClient(token=TOKEN) + client._session.get = MagicMock() + with patch("brainstem_api_tools.cli.BrainstemClient", return_value=client), \ + patch("sys.argv", ["brainstem", "load", "session", + "--limit", "20", "--offset", "30"]): + with pytest.raises(SystemExit): + cli_module.main() + assert "multiple of limit" in capsys.readouterr().err + client._session.get.assert_not_called()