Skip to content
109 changes: 109 additions & 0 deletions cirro/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,112 @@
"""
Python SDK and command-line interface for the [Cirro](https://cirro.bio) platform.

Install with `pip install cirro`.

## Authentication

`DataPortal` needs to know which Cirro instance to talk to and how to
authenticate. The instance comes from the `base_url` argument, falling back to
the `CIRRO_BASE_URL` environment variable, then to the saved configuration in
`~/.cirro/config.ini` (written by `cirro configure`).

There are three ways to authenticate:

1. **Interactive (the default).** `DataPortal()` uses the saved configuration.
If none exists it starts a device-code login, prints a URL, and **blocks
until the user completes the login in a browser**. Avoid this in scripts and
automated sessions -- there is nobody to click the link, so it will hang
until the device code expires.

2. **Headless.** OAuth client credentials never prompt, so this is the option
to use for automation:

```python
import os
from cirro import CirroApi, DataPortal
from cirro.auth.client_creds import ClientCredentialsAuth
from cirro.config import AppConfig

config = AppConfig(base_url="app.cirro.bio")
auth = ClientCredentialsAuth(
os.environ["CIRRO_CLIENT_ID"],
os.environ["CIRRO_CLIENT_SECRET"],
auth_endpoint=config.auth_endpoint
)
portal = DataPortal(client=CirroApi(auth_info=auth))
```

See [OAuth Apps](https://docs.cirro.bio/cli-sdk/oauth-apps/) for how to
create the client ID and secret.

3. **Non-blocking browser login.** `cirro.sdk.login.DataPortalLogin` returns
the authorization message so you can display it yourself, and blocks only
when you call `await_completion()`.

## Quickstart

```python
from cirro import DataPortal

portal = DataPortal(base_url="app.cirro.bio")

# Browse
for project in portal.list_projects():
print(project.name)

# Read a file straight into a DataFrame, without downloading it
df = portal.read_file("Name of Project", "Name of Dataset", glob="*.csv")

# Launch an analysis on an existing dataset
dataset = portal.get_dataset(project="Name of Project", dataset="Name of Dataset")
new_dataset_id = dataset.run_analysis(
name="Name of the output dataset",
process="Name or ID of the process to run",
params={}
)
```

## Object model

- `cirro.sdk.portal.DataPortal` -- entry point; lists projects, processes,
and reference types.
- `cirro.sdk.project.DataPortalProject` -- a permissions boundary holding
datasets and reference data; uploads new datasets.
- `cirro.sdk.dataset.DataPortalDataset` -- a collection of files, either
uploaded or produced by an analysis; reads files and launches analyses.
- `cirro.sdk.file.DataPortalFile` -- one file; read it into memory
(`read_csv`, `read_json`, ...) or download it.
- `cirro.sdk.process.DataPortalProcess` -- a pipeline that can be run, or a
data type that datasets can be uploaded as.
- `cirro.sdk.task.DataPortalTask` -- one task from a Nextflow execution, used
for debugging failed analyses.
- `cirro.sdk.reference.DataPortalReference` -- reference data (genomes,
annotations) available to a project.
- `cirro.cirro_client.CirroApi` -- the lower-level typed API client; use it
when the classes above do not cover what you need.

Projects, datasets, and processes can be looked up by either name or ID --
`get_project`, `get_dataset`, and `run_analysis` all accept either.

Every `list_*` method returns a `list` subclass
(`cirro.sdk.asset.DataPortalAssets`) with extra lookup helpers:
`get_by_name`, `get_by_id`, and `filter_by_pattern`.

## Freshness

These objects hold a snapshot of what the API returned when they were built.
Properties such as `cirro.sdk.dataset.DataPortalDataset.status` and
`cirro.sdk.dataset.DataPortalDataset.logs` will not change on an object you
already have. To watch a running analysis, call `portal.get_dataset(...)` again
each time round the loop.

## Worked examples

The [samples directory](https://github.com/CirroBio/Cirro-SDK-Python/tree/main/samples)
holds runnable notebooks for uploading, downloading, reading files, running and
debugging analyses, managing reference data, and integrating pipelines.
"""

import cirro.file_utils # noqa
from cirro.cirro_client import CirroApi
from cirro.sdk.dataset import DataPortalDataset
Expand Down
8 changes: 7 additions & 1 deletion cirro/cirro_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ def __init__(self, auth_info: AuthInfo = None, base_url: str = None, user_agent:
Instantiates the Cirro API object

Args:
auth_info (cirro.auth.base.AuthInfo):
auth_info (`cirro.auth.base.AuthInfo`): How to authenticate. If
omitted, this is read from the saved configuration, which falls back
to an interactive device-code login that blocks on a browser flow.
Pass `cirro.auth.client_creds.ClientCredentialsAuth` to authenticate
without prompting.
base_url (str): Optional base URL of the Cirro instance
(if not provided, it uses the `CIRRO_BASE_URL` environment variable, or the config file)
user_agent (str): Name reported to the API for this client, which
shows up in Cirro's audit logs.

Returns:
Authenticated Cirro API object, which can be used to call endpoint functions.
Expand Down
20 changes: 20 additions & 0 deletions cirro/sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
The high-level, object-oriented interface to Cirro.

Start from `cirro.sdk.portal.DataPortal`, which is re-exported as
`cirro.DataPortal`. Every other class in this package is reached from it rather
than constructed directly:

```
DataPortal
├── list_projects() -> DataPortalProject
│ ├── list_datasets() -> DataPortalDataset
│ │ ├── list_files() -> DataPortalFile
│ │ └── tasks -> DataPortalTask
│ └── list_references() -> DataPortalReference
├── list_processes() -> DataPortalProcess
└── list_reference_types() -> DataPortalReferenceType
```

For the lower-level typed API client, see `cirro.cirro_client.CirroApi`.
"""
74 changes: 67 additions & 7 deletions cirro/sdk/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@


class DataPortalAsset:
"""Base class used for all Data Portal Assets"""
"""
Base class used for all Data Portal Assets.

Assets are not constructed directly -- each one is obtained from a method on
`cirro.sdk.portal.DataPortal` or on another asset.
"""

@property
@abstractmethod
Expand All @@ -23,7 +28,22 @@ def __repr__(self):

class DataPortalAssets(List[T]):
"""
Generic class with helper functions for any group of assets (projects, datasets, etc.)
A `list` of assets (projects, datasets, files, ...) with lookup helpers.

Every `list_*` method in the SDK returns one of these rather than a plain
list, so anything you can do with a list works, plus lookup by name or ID
and filtering by wildcard:

```python
projects = portal.list_projects()

for project in projects: # ordinary list iteration
print(project.name)

project = projects.get_by_name("My Project")
subset = projects.filter_by_pattern("RNA-seq*")
print(projects.description()) # printable summary of them all
```
"""

# Overridden by child classes
Expand All @@ -35,16 +55,32 @@ def __init__(self, input_list: List[T]):
def __str__(self):
return "\n".join([str(i) for i in self])

def description(self):
"""Render a text summary of the assets."""
def description(self) -> str:
"""
Render a text summary of the assets, one block per asset.
"""

return '\n\n---\n\n'.join([
str(i)
for i in self
])

def get_by_name(self, name: str) -> T:
"""Return the item which matches with name attribute."""
"""
Return the single item whose `name` attribute matches exactly.

Args:
name (str): Name to match. Matching is exact and case-sensitive;
use `filter_by_pattern` for wildcards.

Returns:
The matching item.

Raises:
DataPortalInputError: if `name` is None, or if several items share
the name -- in which case use `get_by_id`.
DataPortalAssetNotFound: if nothing matches.
"""

if name is None:
raise DataPortalInputError(f"Must provide name to identify {self.asset_name}")
Expand All @@ -65,7 +101,21 @@ def get_by_name(self, name: str) -> T:
return matching_queries[0]

def get_by_id(self, _id: str) -> T:
"""Return the item which matches by id attribute."""
"""
Return the single item whose `id` attribute matches exactly.

For files, the `id` is the relative path within the dataset.

Args:
_id (str): ID to match.

Returns:
The matching item.

Raises:
DataPortalInputError: if `_id` is None.
DataPortalAssetNotFound: if nothing matches.
"""

if _id is None:
raise DataPortalInputError(f"Must provide id to identify {self.asset_name}")
Expand All @@ -81,7 +131,17 @@ def get_by_id(self, _id: str) -> T:
return matching_queries[0]

def filter_by_pattern(self, pattern: str) -> 'DataPortalAssets[T]':
"""Filter the items to just those whose name attribute matches the pattern."""
"""
Return the items whose `name` matches a shell-style wildcard pattern.

Args:
pattern (str): Wildcard pattern, matched with `fnmatch` -- `*` for
any run of characters, `?` for one, `[seq]` for a character set.

Returns:
A new collection of the same type holding the matching items, empty
if none match.
"""

# Get a list of the names to search against
all_names = [i.name for i in self]
Expand Down
Loading
Loading