diff --git a/AGENTS.md b/AGENTS.md index db11fcb..b554d99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,9 @@ In addition, there are facilities to help users record new free-form data: thei Data is primarily collected through the human's phone; the human installs [Context by Fulcra](https://apps.apple.com/us/app/context-by-fulcra-health-hub/id1633037434) and lets the app sync their data to their account. +## Repository Development + +Always use `uv` to run Python commands in this repository. For example, run tests with `uv run python -m pytest [options...]`; do not invoke `python`, `python -m pytest`, or `pytest` directly. ### Interactive Access to the User's Data The human user gets to investigate their data interactively using beautiful mobile and [web apps](https://context.fulcradynamics.com/). @@ -16,7 +19,7 @@ The human user gets to investigate their data interactively using beautiful mobi ### Agentic/Programmatic Access To the User's Data * Fully supported [OAuth2 REST API](https://fulcradynamics.github.io/developer-docs/): * [OpenAPI spec](https://api.fulcradynamics.com/openapi.json) -* [Python client library](https://fulcradynamics.github.io/fulcra-api-python/) (`pip install fulcra-api`): For an easy way to use the client library. Handles authentication for you. +* [Python client library](https://fulcradynamics.github.io/fulcra-api-python/) (`pip install fulcra-api`): For an easy way to use the client library. Handles authentication for you. Also installs the `fulcra` command-line tool (see "Command-Line Interface" below). * [MCP Server Docs](https://fulcradynamics.github.io/developer-docs/mcp-server/): A guide on how to use the MCP server. * [MCP server](https://mcp.fulcradynamics.com): The endpoint to the public MCP server. The server uses Streamable HTTP transport with OAuth2 authorization. Context users can use this server with their own account to securely access their data. * [MCP server source code](https://github.com/fulcradynamics/fulcra-context-mcp): The open-source repository for the MCP server. Useful for inspecting available tools, running locally, or contributing. @@ -41,6 +44,10 @@ Type "help", "copyright", "credits" or "license" for more information. The `authorize()` call will poll while the user reaches the URL. If the call times out, call `authorize()` again to get a new URL. +#### Shell-first agents + +Agents that can run shell commands can use the `fulcra` CLI (installed with `pip install fulcra-api`). `fulcra auth login` runs the same device authorization flow and caches credentials in `~/.config/fulcra/credentials.json`, so subsequent commands don't need to re-authenticate. For non-interactive use, `fulcra auth login --get-auth-url` prints the auth URL and a device code without polling; send the URL to the user, then complete with `fulcra auth login --device-code `. + #### Text-first agents For agents without the ability to run Python code, use the [MCP server](https://mcp.fulcradynamics.com). This server includes tools that can access the same data sources that the API can. @@ -131,7 +138,71 @@ data = fulcra.metric_time_series( ) ``` -### Jupyter Notebook Demos +## Command-Line Interface + +Installing the `fulcra-api` package provides a `fulcra` command (also available as `fulcra-api`). Sub-commands return JSON by default, designed for piping into tools like `jq`. + +Typical flow: + +```sh +fulcra auth login # one-time device auth; credentials are cached +fulcra catalog # list queryable Fulcra data types +fulcra user-info # info about the authenticated user +fulcra metric-time-series HeartRate "1 day" --sample-rate 3600 +fulcra sleep-cycles "1 week" +``` + +Notes: + +- Time ranges can be given as two ISO8601 start/end arguments or a single relative interval like `"1 week"`, `"2 days"`, or `"3h"`. Ordinary query commands accept naive absolute timestamps, localize them to the machine's local timezone, and convert them to UTC. Timestamps that define access boundaries, such as group or share start and end times, must include an explicit timezone offset. +- Command families: data queries (`metric-time-series`, `sleep-cycles`, `sleep-stages`, `sleep-cycles-aggregated`, `location-at-time`, `location-time-series`, `apple-workouts`, `calendar-events`, `get-records`, `data-updates`, ...), data writing (`record`, `delete`), and management sub-command groups (`auth`, `data-type`, `file`, `share`, `tag`, `group`). +- `fulcra --help` and `fulcra --help` document every option. +- `fulcra auth print-access-token` prints the OAuth2 access token, useful for calling the REST API directly. + +## Data Groups + +Data groups let a group owner collect read-only shared data from other Fulcra users who opt in. When a participant joins a group, they share the group's declared data types, within the group's declared time range, with the owner — until they leave. Most group parameters are immutable after creation, so the terms participants agreed to can't be changed later. Participant IDs are anonymized, per-group UUIDs that don't reveal the participant's Fulcra UserID. + +Groups created through this library and CLI are always private (not publicly listed); creating public groups is not available to normal users. + +### Python API + +On `FulcraAPI`: + +- Discovery/membership: `get_groups(subscribed_only=...)`, `get_group(group_id)`, `join_group(group_id)`, `leave_group(group_id)` +- Owner operations: `create_group(...)`, `update_group(group_id, ...)` (only description, header/preview image URLs, and view description are editable), `delete_group(group_id)`, `get_group_participants(group_id)`, `get_group_jwks()` +- Participant metadata (owner only): `get_group_participant_metadata`, `set_group_participant_metadata` (replace), `update_group_participant_metadata` (merge) +- Data access: `group_participant(group_id, participant_id)` returns a `FulcraGroupParticipant` accessor with the same data-access methods as the client (`metric_time_series`, `metric_samples`, `sleep_agg`, annotations, ...), scoped to that participant's shared data. Requests outside the group's data types or time range are rejected by the server. + +```python +for pid in fulcra.get_group_participants(group_id): + participant = fulcra.group_participant(group_id, pid) + df = participant.metric_time_series( + start_time="2026-07-01T00:00:00Z", + end_time="2026-07-02T00:00:00Z", + metric="StepCount", + ) +``` + +### CLI + +`fulcra group` sub-commands: `list` (public groups, or `--joined` for your memberships), `show`, `create`, `update`, `delete`, `join`, `leave`, `participants`, `get-metadata`, `set-metadata`, `update-metadata`, and `jwks` (public keys for validating participant JWTs). + +```sh +fulcra group create --title "Step Challenge" \ + --responsible-entity "Fulcra Dynamics" \ + --description "A month-long step challenge." \ + --data-type StepCount --url https://example.com/challenge +``` + +To query a participant's shared data, pass `--group-id` and `--participant-id` (both required together) to the data query commands (`metric-time-series`, the sleep and location commands, `apple-workouts`, and `get-records`; not the calendar commands): + +```sh +fulcra metric-time-series StepCount "1 week" \ + --group-id --participant-id +``` + +## Jupyter Notebook Demos Ready-to-run demo notebooks are available at the [Fulcra demos repository](https://github.com/fulcradynamics/demos). These notebooks walk through common use cases like querying health metrics, analyzing sleep, and correlating data across domains. They can also be opened directly in [Google Colab](https://colab.research.google.com/) for one-click, zero-install demos. diff --git a/README.md b/README.md index 4aa3fb3..ed41b02 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Usage: fulcra [OPTIONS] COMMAND [ARGS]... like `jq` for parsing and filtering. Options: + --beta Enable beta features --help Show this message and exit. Commands: @@ -30,15 +31,24 @@ Commands: calendars Return Apple calendars catalog Return a list of queryable Fulcra data types and metadata + data-type Data type management sub-commands + data-updates Return data/file updates that occurred during a + period + delete Delete records for a data type + file File management sub-commands get-records Return raw sample records for a data type google-location-updates Return Google Maps location update records + group Data group management sub-commands location-at-time Return location at specified time location-time-series Return a calculated time series of location data metric-time-series Return a calculated time series for a metric + record Record data for a data type + share Data sharing management sub-commands sleep-cycles Return sleep cycles summarized from sleep stages sleep-cycles-aggregated Return sleep cycles aggregated by a specific period sleep-stages Return sleep stages derived from sleep-related metric records + tag Tag management sub-commands user-info Return information about the authenticated user ``` diff --git a/docs/fulcraapi.md b/docs/fulcraapi.md index 7e60c4d..9bf4d26 100644 --- a/docs/fulcraapi.md +++ b/docs/fulcraapi.md @@ -2,3 +2,8 @@ handler: python options: show_source: false + +### ::: fulcra_api.core.FulcraGroupParticipant + handler: python + options: + show_source: false diff --git a/fulcra_api/cli/__init__.py b/fulcra_api/cli/__init__.py index 55b89b6..388520e 100644 --- a/fulcra_api/cli/__init__.py +++ b/fulcra_api/cli/__init__.py @@ -25,6 +25,7 @@ ) from .data_types import data_type from .files import file +from .groups import group from .record import delete_records, record from .share import share from .tags import tag @@ -53,6 +54,7 @@ def cli(ctx, beta): cli.add_command(data_type) cli.add_command(file) cli.add_command(share) +cli.add_command(group) cli.add_command(record) cli.add_command(delete_records) diff --git a/fulcra_api/cli/commands.py b/fulcra_api/cli/commands.py index 436ec16..3a4ff63 100644 --- a/fulcra_api/cli/commands.py +++ b/fulcra_api/cli/commands.py @@ -9,10 +9,12 @@ from fulcra_api.core import FulcraAPI from .utils import ( + group_participant_options, parse_time, pass_fulcra_api, related_cli_commands, requires_auth, + resolve_data_source, resolve_data_type, time_range, ) @@ -55,18 +57,24 @@ def list_calendar_events( @click.command("apple-workouts", short_help="Return Apple workouts") @time_range +@group_participant_options @pass_fulcra_api @requires_auth def list_apple_workouts( - fulcra_api: FulcraAPI, start_time: datetime, end_time: datetime + fulcra_api: FulcraAPI, + start_time: datetime, + end_time: datetime, + group_id, + participant_id, ): """Return Apple Workout records across TIME_RANGE. TIME_RANGE: Two start & end date arguments in ISO8601 format or a single interval argument relative to the current time ("1 week", "2 days", "3h", etc.) """ + source = resolve_data_source(fulcra_api, group_id, participant_id) try: - results = fulcra_api.apple_workouts(start_time, end_time) + results = source.apple_workouts(start_time, end_time) except HTTPError as exc: raise click.ClickException(exc) @@ -101,6 +109,7 @@ def list_apple_workouts( default=None, help="Aggregate functions (max, min, delta, mean, uniques, allpoints, rollingmean) to apply to time series window, can be passed multiple times.", ) +@group_participant_options @pass_fulcra_api @requires_auth def metric_time_series( @@ -111,6 +120,8 @@ def metric_time_series( sample_rate: int, replace_nulls: bool, agg_function: Tuple[str], + group_id, + participant_id, ): """Return calculated time series data for METRIC across TIME_RANGE. @@ -134,7 +145,8 @@ def metric_time_series( f"{data_type[0]['id']} cannot be returned with metric-time-series, use `fulcra get-records {metric}` instead to return raw sample records." ) - df = fulcra_api.metric_time_series( + source = resolve_data_source(fulcra_api, group_id, participant_id) + df = source.metric_time_series( start_time, end_time, metric, @@ -153,18 +165,24 @@ def metric_time_series( "google-location-updates", short_help="Return Google Maps location update records" ) @time_range +@group_participant_options @pass_fulcra_api @requires_auth def google_location_updates( - fulcra_api: FulcraAPI, start_time: datetime, end_time: datetime + fulcra_api: FulcraAPI, + start_time: datetime, + end_time: datetime, + group_id, + participant_id, ): """Return raw Google location update sample records across TIME_RANGE. TIME_RANGE: Two start & end date arguments in ISO8601 format or a single interval argument relative to the current time ("1 week", "2 days", "3h", etc.) """ + source = resolve_data_source(fulcra_api, group_id, participant_id) try: - results = fulcra_api.gmaps_location_updates(start_time, end_time) + results = source.gmaps_location_updates(start_time, end_time) except HTTPError as exc: raise click.ClickException(exc) @@ -176,18 +194,24 @@ def google_location_updates( "apple-location-updates", short_help="Return Apple location update records" ) @time_range +@group_participant_options @pass_fulcra_api @requires_auth def apple_location_updates( - fulcra_api: FulcraAPI, start_time: datetime, end_time: datetime + fulcra_api: FulcraAPI, + start_time: datetime, + end_time: datetime, + group_id, + participant_id, ): """Return raw Apple location update sample records across TIME_RANGE. TIME_RANGE: Two start & end date arguments in ISO8601 format or a single interval argument relative to the current time ("1 week", "2 days", "3h", etc.) """ + source = resolve_data_source(fulcra_api, group_id, participant_id) try: - results = fulcra_api.apple_location_updates(start_time, end_time) + results = source.apple_location_updates(start_time, end_time) except HTTPError as exc: raise click.ClickException(exc) @@ -199,18 +223,24 @@ def apple_location_updates( "apple-location-visits", short_help="Return Apple location visit records" ) @time_range +@group_participant_options @pass_fulcra_api @requires_auth def apple_location_visits( - fulcra_api: FulcraAPI, start_time: datetime, end_time: datetime + fulcra_api: FulcraAPI, + start_time: datetime, + end_time: datetime, + group_id, + participant_id, ): """Return raw Apple location visit sample records across TIME_RANGE. TIME_RANGE: Two start & end date arguments in ISO8601 format or a single interval argument relative to the current time ("1 week", "2 days", "3h", etc.) """ + source = resolve_data_source(fulcra_api, group_id, participant_id) try: - results = fulcra_api.apple_location_visits(start_time, end_time) + results = source.apple_location_visits(start_time, end_time) except HTTPError as exc: raise click.ClickException(exc) @@ -243,6 +273,7 @@ def apple_location_visits( default=False, help="Reverse geolocate coordinates.", ) +@group_participant_options @pass_fulcra_api @requires_auth def location_time_series( @@ -253,13 +284,16 @@ def location_time_series( sample_rate: int, look_back: int, reverse_geocode: bool, + group_id, + participant_id, ): """Return a computed time series of visited locations across TIME_RANGE. This uses the most precise underlying data sources available at the given time. TIME_RANGE: Two start & end date arguments in ISO8601 format or a single interval argument relative to the current time ("1 week", "2 days", "3h", etc.) """ + source = resolve_data_source(fulcra_api, group_id, participant_id) try: - results = fulcra_api.location_time_series( + results = source.location_time_series( start_time, end_time, change_meters, sample_rate, look_back, reverse_geocode ) except HTTPError as exc: @@ -291,6 +325,7 @@ def location_time_series( default=False, help="Reverse geolocate coordinates.", ) +@group_participant_options @pass_fulcra_api @requires_auth def location_at_time( @@ -299,6 +334,8 @@ def location_at_time( window_size: int, include_after: bool, reverse_geocode: bool, + group_id, + participant_id, ): """Return the location at specified TIME. @@ -307,8 +344,9 @@ def location_at_time( If no sample is available for the exact time, searches for the closest sample up to `window_size` seconds back. If `--include_after` is passed then also searches `window_size` seconds forward. """ + source = resolve_data_source(fulcra_api, group_id, participant_id) try: - results = fulcra_api.location_at_time( + results = source.location_at_time( time, window_size, include_after, reverse_geocode ) except HTTPError as exc: @@ -361,6 +399,7 @@ def location_at_time( default=False, help="Do not clip the data to the requested date range.", ) +@group_participant_options @pass_fulcra_api @requires_auth def sleep_stages( @@ -373,6 +412,8 @@ def sleep_stages( no_merge_overlapping: bool, no_merge_contiguous: bool, no_clip_to_range: bool, + group_id, + participant_id, ): """Return computed sleep stages from sleep data over TIME_RANGE. @@ -410,8 +451,9 @@ def sleep_stages( if no_clip_to_range: kwargs["clip_to_range"] = False + source = resolve_data_source(fulcra_api, group_id, participant_id) try: - df = fulcra_api.sleep_stages(**kwargs) + df = source.sleep_stages(**kwargs) except HTTPError as exc: raise click.ClickException(exc) @@ -451,6 +493,7 @@ def sleep_stages( default=False, help="Do not clip the data to the requested date range.", ) +@group_participant_options @pass_fulcra_api @requires_auth def sleep_cycles( @@ -461,6 +504,8 @@ def sleep_cycles( stage: Optional[Tuple[int]], gap_stage: Optional[Tuple[int]], no_clip_to_range: bool, + group_id, + participant_id, ): """Return computed sleep cycles summarized from sleep stages over TIME_RANGE. @@ -481,8 +526,9 @@ def sleep_cycles( if no_clip_to_range: kwargs["clip_to_range"] = False + source = resolve_data_source(fulcra_api, group_id, participant_id) try: - df = fulcra_api.sleep_cycles(**kwargs) + df = source.sleep_cycles(**kwargs) except HTTPError as exc: raise click.ClickException(exc) @@ -542,6 +588,7 @@ def sleep_cycles( default=False, help="Do not clip the data to the requested date range.", ) +@group_participant_options @pass_fulcra_api @requires_auth def sleep_cycles_aggregated( @@ -556,6 +603,8 @@ def sleep_cycles_aggregated( period: Optional[str], function: Tuple[str], time_zone: Optional[str], + group_id, + participant_id, ): """Return computed sleep cycles aggregated by a specific function over TIME_RANGE. @@ -583,8 +632,9 @@ def sleep_cycles_aggregated( if function: kwargs["agg_functions"] = list(function) + source = resolve_data_source(fulcra_api, group_id, participant_id) try: - df = fulcra_api.sleep_agg(**kwargs) + df = source.sleep_agg(**kwargs) except HTTPError as exc: raise click.ClickException(exc) @@ -607,6 +657,7 @@ def sleep_cycles_aggregated( callback=resolve_data_type(allow_multiple=True, user_id_param="user_id"), ) @time_range +@group_participant_options @pass_fulcra_api @requires_auth def get_records( @@ -615,6 +666,8 @@ def get_records( start_time: datetime, end_time: datetime, user_id: str | None, + group_id, + participant_id, ): """Return raw sample records of DATA_TYPE across TIME_RANGE. @@ -636,6 +689,9 @@ def get_records( """ # data_type is a list of resolved catalog entries (see resolve_data_type) + if user_id and group_id: + raise click.UsageError("--user-id cannot be used with --group-id") + source = resolve_data_source(fulcra_api, group_id, participant_id) authenticated_user_id = fulcra_api.get_fulcra_userid() results = [] @@ -656,30 +712,27 @@ def get_records( record_type = dt.get("record_spec", {}).get("type") if dt["api_version"] == "v0" and record_type == "metric": - query_func = fulcra_api.metric_samples + query_func = source.metric_samples kwargs = { "start_time": start_time, "end_time": end_time, "metric": dt["id"], } - if authenticated_user_id != dt["fulcra_userid"]: + if ( + source is fulcra_api + and authenticated_user_id != dt["fulcra_userid"] + ): kwargs["fulcra_userid"] = dt["fulcra_userid"] - elif dt["api_version"] == "v1alpha1" and record_type == "metric": - query_func = fulcra_api.fulcra_v1_api_path - path = f"{record_type}/{base_type}" - if user_annotation_id: - path = f"{path}/{user_annotation_id}" - params = {"start_time": start_time, "end_time": end_time} - if authenticated_user_id != dt["fulcra_userid"]: - params["fulcra_userid"] = dt["fulcra_userid"] - kwargs = {"path": path, "params": params} - elif dt["api_version"] == "v1alpha1" and record_type == "event": - query_func = fulcra_api.fulcra_v1_api_path + elif dt["api_version"] == "v1alpha1" and record_type in ("metric", "event"): + query_func = source.fulcra_v1_api_path path = f"{record_type}/{base_type}" if user_annotation_id: path = f"{path}/{user_annotation_id}" params = {"start_time": start_time, "end_time": end_time} - if authenticated_user_id != dt["fulcra_userid"]: + if ( + source is fulcra_api + and authenticated_user_id != dt["fulcra_userid"] + ): params["fulcra_userid"] = dt["fulcra_userid"] kwargs = {"path": path, "params": params} else: diff --git a/fulcra_api/cli/groups.py b/fulcra_api/cli/groups.py new file mode 100644 index 0000000..5c3bba1 --- /dev/null +++ b/fulcra_api/cli/groups.py @@ -0,0 +1,455 @@ +import json +from urllib.error import HTTPError + +import click + +from fulcra_api.core import FulcraAPI + +from .utils import parse_iso_time, parse_json_object, pass_fulcra_api, requires_auth + + +@click.group(help="Data group management sub-commands") +def group(): + pass + + +@group.command("list", short_help="List public groups, or groups you've joined") +@click.option( + "--joined", + is_flag=True, + default=False, + help="List only groups you have joined, including participant ID", +) +@pass_fulcra_api +@requires_auth +def list_groups(fulcra_api: FulcraAPI, joined: bool): + """ + List data groups. + + By default, lists all public groups. With --joined, lists only the + groups you have joined; these include your participant ID and join time. + """ + try: + results = fulcra_api.get_groups(subscribed_only=joined) + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to retrieve groups: {exc}\n{error_body}") + + for grp in results: + click.echo(json.dumps(grp)) + + +@group.command("show", short_help="Show a group's description") +@click.argument("group_id") +@pass_fulcra_api +@requires_auth +def show(fulcra_api: FulcraAPI, group_id: str): + """ + Show the description of a data group. + + GROUP_ID: UUID of the group + """ + try: + result = fulcra_api.get_group(group_id) + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to retrieve group: {exc}\n{error_body}") + + click.echo(json.dumps(result)) + + +@group.command("create", short_help="Create a new group") +@click.option("--title", required=True, help="Title") +@click.option( + "--responsible-entity", + required=True, + help="The person or organization responsible for the group", +) +@click.option("--description", required=True, help="Description of the group") +@click.option( + "--data-type", + "data_types", + multiple=True, + required=True, + help="Data type ID that participants will share (can be specified multiple times)", +) +@click.option("--url", "group_url", required=True, help="URL of the group's webapp") +@click.option("--start-time", type=str, help="Optional start time (ISO8601 format)") +@click.option("--end-time", type=str, help="Optional end time (ISO8601 format)") +@click.option("--detail-markdown", help="Markdown shown on the group's detail view") +@click.option("--agreement-markdown", help="Markdown shown when a user joins") +@click.option("--withdraw-markdown", help="Markdown shown when a user leaves") +@click.option("--header-image-url", help="URL of the group's header image") +@click.option("--preview-image-url", help="URL of the group's preview image") +@click.option("--friendly-id", help="Human-friendly identifier for the group") +@pass_fulcra_api +@requires_auth +def create( + fulcra_api: FulcraAPI, + title, + responsible_entity, + description, + data_types, + group_url, + start_time, + end_time, + detail_markdown, + agreement_markdown, + withdraw_markdown, + header_image_url, + preview_image_url, + friendly_id, +): + """ + Create a new data group that other Fulcra users can join. + + Participants who join share read-only access to the selected data types + for the selected time range until they leave the group. Most group + parameters are immutable after creation; see 'fulcra group update' for + the fields that can be changed later. + + Examples: + + \b + Create a group: + fulcra group create --title "Step Challenge" \\ + --responsible-entity "Fulcra Dynamics" \\ + --description "A month-long step challenge." \\ + --data-type StepCount --url https://example.com/challenge + """ + # Validate data types against catalog + try: + catalog = fulcra_api.v1_catalog() + valid_data_type_ids = {item["id"] for item in catalog} + + # "apple_workouts" is the resource name the group data routes check for + # workout access, but it is not a catalog ID. + temporary_allowed_types = {"apple_workouts"} + + invalid_types = [ + dt + for dt in data_types + if dt not in valid_data_type_ids and dt not in temporary_allowed_types + ] + if invalid_types: + raise click.ClickException( + f"Invalid data type(s): {', '.join(invalid_types)}. " + f"Use 'fulcra catalog' to see valid data types." + ) + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to fetch catalog: {exc}\n{error_body}") + + parsed_start_time = ( + parse_iso_time(start_time, "start time") if start_time else None + ) + parsed_end_time = parse_iso_time(end_time, "end time") if end_time else None + + try: + result = fulcra_api.create_group( + title=title, + responsible_entity=responsible_entity, + description=description, + fulcra_data_types=sorted(data_types), + group_url=group_url, + time_start=parsed_start_time, + time_end=parsed_end_time, + detail_markdown=detail_markdown, + agreement_markdown=agreement_markdown, + withdraw_markdown=withdraw_markdown, + header_image_url=header_image_url, + preview_image_url=preview_image_url, + friendly_id=friendly_id, + ) + click.echo(json.dumps(result)) + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to create group: {exc}\n{error_body}") + + +@group.command("update", short_help="Update a group you own") +@click.argument("group_id") +@click.option("--description", help="New description for the group") +@click.option("--header-image-url", help="New URL of the group's header image") +@click.option( + "--no-header-image-url", + is_flag=True, + default=False, + help="Clear the group's header image", +) +@click.option("--preview-image-url", help="New URL of the group's preview image") +@click.option( + "--no-preview-image-url", + is_flag=True, + default=False, + help="Clear the group's preview image", +) +@click.option( + "--view-description", help="New description of the group's view (JSON object)" +) +@click.option( + "--no-view-description", + is_flag=True, + default=False, + help="Clear the group's view description", +) +@pass_fulcra_api +@requires_auth +def update( + fulcra_api: FulcraAPI, + group_id: str, + description, + header_image_url, + no_header_image_url, + preview_image_url, + no_preview_image_url, + view_description, + no_view_description, +): + """ + Update the editable fields of a group that you own. + + Only these fields can be changed after creation; all other group + parameters are immutable. Fields not specified are left unchanged. + + GROUP_ID: UUID of the group to update + """ + if header_image_url and no_header_image_url: + raise click.UsageError( + "--header-image-url cannot be used with --no-header-image-url" + ) + if preview_image_url and no_preview_image_url: + raise click.UsageError( + "--preview-image-url cannot be used with --no-preview-image-url" + ) + if view_description and no_view_description: + raise click.UsageError( + "--view-description cannot be used with --no-view-description" + ) + + kwargs = {} + if description: + kwargs["description"] = description + if header_image_url: + kwargs["header_image_url"] = header_image_url + elif no_header_image_url: + kwargs["header_image_url"] = None + if preview_image_url: + kwargs["preview_image_url"] = preview_image_url + elif no_preview_image_url: + kwargs["preview_image_url"] = None + if view_description: + kwargs["view_description"] = parse_json_object( + view_description, "--view-description" + ) + elif no_view_description: + kwargs["view_description"] = None + + if not kwargs: + raise click.UsageError("Must specify at least one option to update") + + try: + result = fulcra_api.update_group(group_id=group_id, **kwargs) + click.echo(json.dumps(result)) + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to update group: {exc}\n{error_body}") + + +@group.command("delete", short_help="Delete a group you own") +@click.argument("group_id") +@pass_fulcra_api +@requires_auth +def delete(fulcra_api: FulcraAPI, group_id: str): + """ + Delete a group that you own. + + GROUP_ID: UUID of the group to delete + """ + try: + fulcra_api.delete_group(group_id) + click.echo(f"Group {group_id} deleted successfully") + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to delete group: {exc}\n{error_body}") + + +@group.command("join", short_help="Join a group") +@click.argument("group_id") +@pass_fulcra_api +@requires_auth +def join(fulcra_api: FulcraAPI, group_id: str): + """ + Join a data group as a participant. + + Joining shares read-only access to your data (limited to the group's + data types and time range) with the group's owner until you leave. + + GROUP_ID: UUID of the group to join + """ + try: + result = fulcra_api.join_group(group_id) + click.echo(json.dumps(result)) + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to join group: {exc}\n{error_body}") + + +@group.command("leave", short_help="Leave a group") +@click.argument("group_id") +@pass_fulcra_api +@requires_auth +def leave(fulcra_api: FulcraAPI, group_id: str): + """ + Leave a data group, revoking the owner's access to your data. + + GROUP_ID: UUID of the group to leave + """ + try: + fulcra_api.leave_group(group_id) + click.echo(f"Successfully left group {group_id}") + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to leave group: {exc}\n{error_body}") + + +@group.command("participants", short_help="List participants of a group you own") +@click.argument("group_id") +@pass_fulcra_api +@requires_auth +def participants(fulcra_api: FulcraAPI, group_id: str): + """ + List the participant IDs of a group that you own. + + Participant IDs are anonymized UUIDs that are only meaningful within + this group; they do not reveal participants' Fulcra UserIDs. + + GROUP_ID: UUID of the group + """ + try: + results = fulcra_api.get_group_participants(group_id) + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException( + f"Failed to retrieve participants: {exc}\n{error_body}" + ) + + for participant_id in results: + click.echo(participant_id) + + +@group.command("get-metadata", short_help="Get a participant's metadata") +@click.argument("group_id") +@click.argument("participant_id") +@pass_fulcra_api +@requires_auth +def get_metadata(fulcra_api: FulcraAPI, group_id: str, participant_id: str): + """ + Get the metadata object for a participant in a group you own. + + GROUP_ID: UUID of the group + + PARTICIPANT_ID: Participant ID within the group + """ + try: + result = fulcra_api.get_group_participant_metadata(group_id, participant_id) + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to retrieve metadata: {exc}\n{error_body}") + + click.echo(json.dumps(result)) + + +@group.command("set-metadata", short_help="Replace a participant's metadata") +@click.argument("group_id") +@click.argument("participant_id") +@click.argument("metadata") +@pass_fulcra_api +@requires_auth +def set_metadata( + fulcra_api: FulcraAPI, group_id: str, participant_id: str, metadata: str +): + """ + Replace the entire metadata object for a participant in a group you own. + + To modify individual values instead, use 'fulcra group update-metadata'. + + GROUP_ID: UUID of the group + + PARTICIPANT_ID: Participant ID within the group + + METADATA: The new metadata object, as JSON + + Examples: + + \b + fulcra group set-metadata '{"nickname": "speedy"}' + """ + parsed_metadata = parse_json_object(metadata, "METADATA") + + try: + fulcra_api.set_group_participant_metadata( + group_id, participant_id, parsed_metadata + ) + click.echo(f"Metadata set for participant {participant_id}") + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to set metadata: {exc}\n{error_body}") + + +@group.command("update-metadata", short_help="Update values on a participant's metadata") +@click.argument("group_id") +@click.argument("participant_id") +@click.argument("values") +@pass_fulcra_api +@requires_auth +def update_metadata( + fulcra_api: FulcraAPI, group_id: str, participant_id: str, values: str +): + """ + Update some values on a participant's metadata in a group you own. + + The given values are merged into the participant's existing metadata; + other values are left unchanged. To replace the entire object, use + 'fulcra group set-metadata'. + + GROUP_ID: UUID of the group + + PARTICIPANT_ID: Participant ID within the group + + VALUES: The metadata values to set, as JSON + + Examples: + + \b + fulcra group update-metadata '{"score": 42}' + """ + parsed_values = parse_json_object(values, "VALUES") + + try: + fulcra_api.update_group_participant_metadata( + group_id, participant_id, parsed_values + ) + click.echo(f"Metadata updated for participant {participant_id}") + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to update metadata: {exc}\n{error_body}") + + +@group.command("jwks", short_help="Get the group public keys (JWKS)") +@pass_fulcra_api +@requires_auth +def jwks(fulcra_api: FulcraAPI): + """ + Get the group public keys as a JWKS. + + Group webapps can use these keys to validate the participant JWTs that + Context sends when authenticating requests. + """ + try: + result = fulcra_api.get_group_jwks() + except HTTPError as exc: + error_body = exc.read().decode("utf-8") + raise click.ClickException(f"Failed to retrieve JWKS: {exc}\n{error_body}") + + click.echo(json.dumps(result)) diff --git a/fulcra_api/cli/share.py b/fulcra_api/cli/share.py index b2fa099..cc4ec96 100644 --- a/fulcra_api/cli/share.py +++ b/fulcra_api/cli/share.py @@ -137,23 +137,10 @@ def create( share_types = valid_share_types(fulcra_api=fulcra_api, share_types=share_types) # Parse time arguments if provided - parsed_start_time = None - parsed_end_time = None - if start_time: - try: - parsed_start_time = datetime.fromisoformat(start_time) - except ValueError: - raise click.ClickException( - f"Invalid start time format: {start_time}. Use ISO8601 format." - ) - - if end_time: - try: - parsed_end_time = datetime.fromisoformat(end_time) - except ValueError: - raise click.ClickException( - f"Invalid end time format: {end_time}. Use ISO8601 format." - ) + parsed_start_time = ( + parse_iso_time(start_time, "start time") if start_time else None + ) + parsed_end_time = parse_iso_time(end_time, "end time") if end_time else None if datashare_name is None: datashare_name = ( @@ -543,23 +530,13 @@ def update( # Handle start time if start_time_value: - try: - update_kwargs["time_start"] = datetime.fromisoformat(start_time_value) - except ValueError: - raise click.ClickException( - f"Invalid start time format: {start_time_value}. Use ISO8601 format." - ) + update_kwargs["time_start"] = parse_iso_time(start_time_value, "start time") elif no_start_time: update_kwargs["time_start"] = None # Handle end time if end_time_value: - try: - update_kwargs["time_end"] = datetime.fromisoformat(end_time_value) - except ValueError: - raise click.ClickException( - f"Invalid end time format: {end_time_value}. Use ISO8601 format." - ) + update_kwargs["time_end"] = parse_iso_time(end_time_value, "end time") elif no_end_time: update_kwargs["time_end"] = None diff --git a/fulcra_api/cli/utils.py b/fulcra_api/cli/utils.py index 5a13674..2dfbc85 100644 --- a/fulcra_api/cli/utils.py +++ b/fulcra_api/cli/utils.py @@ -1,3 +1,4 @@ +import json import os import pathlib from datetime import datetime, timezone @@ -49,6 +50,83 @@ def wrapper(fulcra_api, *args, **kwargs): return wrapper +def parse_iso_time(value: str, name: str) -> datetime: + """ + Parse a user-supplied ISO8601 time string, raising a friendly error. + + The timestamp must include a timezone offset; these values define + access boundaries, so we refuse to guess what a naive time means. + + Params: + value: The raw string to parse + name: What the value is, for the error message (e.g. "start time") + """ + try: + dt = datetime.fromisoformat(value) + except ValueError: + raise click.ClickException( + f"Invalid {name} format: {value}. Use ISO8601 format." + ) + if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: + raise click.ClickException( + f"The {name} must include a timezone offset " + f"(e.g. {value}Z or {value}-07:00)." + ) + return dt + + +def parse_json_object(value: str, name: str) -> dict: + """ + Parse a user-supplied JSON object string, raising a friendly error. + + Params: + value: The raw string to parse + name: What the value is, for the error message (e.g. "--annotations") + """ + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise click.ClickException(f"Invalid JSON for {name}: {exc}") + if not isinstance(parsed, dict): + raise click.ClickException(f"{name} must be a JSON object") + return parsed + + +def group_participant_options(f): + """ + Decorator adding --group-id/--participant-id options to a data command, + for querying data shared by a participant of a group the user owns. + """ + f = click.option( + "--participant-id", + type=str, + default=None, + help="Participant ID within the group given by --group-id.", + )(f) + f = click.option( + "--group-id", + type=str, + default=None, + help="Query data shared by a participant of a group you own " + "(requires --participant-id).", + )(f) + return f + + +def resolve_data_source(fulcra_api: FulcraAPI, group_id, participant_id): + """ + Return the object to run a data query against: the client itself, or a + group participant accessor when --group-id/--participant-id were given. + """ + if (group_id is None) != (participant_id is None): + raise click.UsageError( + "--group-id and --participant-id must be used together" + ) + if group_id is not None: + return fulcra_api.group_participant(group_id, participant_id) + return fulcra_api + + def resolve_data_type( *, allow_multiple: bool = False, diff --git a/fulcra_api/core.py b/fulcra_api/core.py index e58c279..e2a620e 100644 --- a/fulcra_api/core.py +++ b/fulcra_api/core.py @@ -37,1876 +37,1937 @@ "FULCRA_OIDC_SCOPE", "openid profile name email offline_access" ) +# Sentinel distinguishing "parameter not passed" from an explicit None, for +# update calls where None means "clear this field on the server". +UNSET: Any = object() -class FulcraAPI: - """ - The main class for making Fulcra API functions. - This contains functions for authorizing a token, authenticating HTTP requests, - making calls, and loading data. +class FulcraDataAccessMixin: + """ + Shared implementations of the data-access operations (v0 endpoints and + v1alpha1 annotation readers). + + These methods are available both on `FulcraAPI` (to access your own + data, or another user's shared data via the `fulcra_userid` parameter) + and on `FulcraGroupParticipant` (to access the data that a group + participant shares with you). Subclasses choose the data source that + requests are made against by implementing `_v0_data_path` and + `fulcra_v1_api`. """ - fulcra_credentials: Optional[FulcraCredentials] = None + def _v0_data_path( + self, operation: str, fulcra_userid: Optional[str] = None + ) -> str: + """ + Build the request path for a v0 data operation. + """ + raise NotImplementedError - def __init__( + def fulcra_api( self, - oidc_domain: Optional[str] = None, - oidc_client_id: Optional[str] = None, - oidc_scope: Optional[str] = None, - oidc_audience: Optional[str] = None, - access_token: Optional[str] = None, - access_token_expiration: Optional[datetime.datetime] = None, - refresh_token: Optional[str] = None, - credentials: Optional[FulcraCredentials] = None, - refresh_callback: Optional[Callable] = None, - ): + url_path: str, + method: str = "GET", + query: Optional[dict] = None, + data: Optional[Union[dict, List[dict]]] = None, + return_http_response: bool = False, + content_type: str = "application/json", + ) -> Any: """ - Initializes the FulcraAPI client. + Make an authenticated request to the Fulcra API. + """ + raise NotImplementedError - Params: - oidc_domain: Optional. The OIDC provider domain to use for authentication. - Defaults to FULCRA_OIDC_DOMAIN. - oidc_client_id: Optional. The OIDC client ID to use. - Defaults to FULCRA_OIDC_CLIENT_ID. - oidc_scope: Optional. The OAuth scopes to request. - Defaults to FULCRA_OIDC_SCOPE. - oidc_audience: Optional. The OIDC audience for the token. - Defaults to FULCRA_OIDC_AUDIENCE. - access_token: Optional. An existing access token to use. [Deprecated] - access_token_expiration: Optional. The expiration datetime for the - provided access_token. [Deprecated] - refresh_token: Optional. An existing refresh token to use. [Deprecated] - credentials: Optional. A FulcraCredentials object with credentials to use. - refresh_callback: Optional. A callback function for when the access token is successfully refreshed. + def fulcra_v1_api( + self, data_class: str, data_type: str, params: dict = {} + ) -> bytes: + """ + Make a call to the v1 API. """ + raise NotImplementedError - # New OIDC provider which should replace most of the oidc workflow functionality here - self.oidc = FulcraOIDCProvider( - domain=oidc_domain or FULCRA_OIDC_DOMAIN, - client_id=oidc_client_id or FULCRA_OIDC_CLIENT_ID, - scope=oidc_scope or FULCRA_OIDC_SCOPE, - audience=oidc_audience or FULCRA_OIDC_AUDIENCE, - ) + def apple_workouts( + self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + fulcra_userid: Optional[str] = None, + ) -> List[Dict]: + """ + Retrieve the list of Apple workouts that occurred (at least partially) during + the specified time range. - self.fulcra_credentials = credentials + Requires an authorized access token. - audience_url = urllib.parse.urlparse(self.oidc.audience) - self.fulcra_api_domain = audience_url.hostname - self.fulcra_api_is_http = False - if audience_url.scheme == "http": - if self.fulcra_api_domain in ["localhost", "127.0.0.1"]: - self.fulcra_api_is_http = True - else: - raise ValueError("HTTP audience scheme only allowed for localhost") - self.fulcra_api_port = audience_url.port + Params: + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. + fulcra_userid: When present, specifies the Fulcra user ID to request data for. - # Support for deprecated constructor credential params - if self.fulcra_credentials is None and ( - access_token is not None - or access_token_expiration is not None - or refresh_token is not None - ): - kwargs = {} - if access_token: - kwargs["access_token"] = access_token - if access_token_expiration: - kwargs["access_token_expiration"] = access_token_expiration - if refresh_token: - kwargs["refresh_token"] = refresh_token + Returns: + A list of dicts, each of which contains the data from a workout. - self.fulcra_credentials = FulcraCredentials(**kwargs) + Examples: + To retrieve all workouts during a time period: - self.refresh_callback = refresh_callback + >>> workouts = fulcra.apple_workouts( + ... start_time = "2023-09-21 07:00:00.000Z", + ... end_time = "2023-09-22 07:00:00.000Z" + ... ) - def get_token( - self, device_code: str - ) -> Tuple[Optional[str], Optional[datetime.datetime], Optional[str]]: - """ - Deprecated. Polls for an access token using a device code. - Used by the device authorization flow. - """ + To inspect the details of a workout: - try: - creds = self.oidc.get_token( - "urn:ietf:params:oauth:grant-type:device_code", - {"device_code": device_code}, - ) - return ( - creds.access_token, - creds.access_token_expiration, - creds.refresh_token, - ) - except Exception as exc: - return (None, None, None) + >>> workouts[0] + {'start_date': '2023-09-21T19:18:31.733000Z', 'end_date': + '2023-09-21T19:49:08.773000Z', 'has_undetermined_duration': False, + 'apple_workout_id': '480b25fe-b229-41b9-bf13-7ccf5e2092ec', 'duration': + 1837.0397539138794, 'extras': {'HKTimeZone': 'America/Los_Angeles', + 'HKAverageMETs': '4.37848 kcal/hr·kg' ... } - def authorize(self): """ - Request a device token, then prompt the user to authorize it. + params = {"start_time": start_time, "end_time": end_time} + resp = self.fulcra_api( + self._v0_data_path("apple_workouts", fulcra_userid), query=params + ) + return json.loads(resp) - This uses the Device Authorization workflow, which requires the user - to visit a link and confirm that the code shown on the screen matches. + def metric_samples( + self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + metric: str, + fulcra_userid: Optional[str] = None, + ) -> List[Dict]: + """ + Retrieve the raw samples related to the given metric that occurred for the + user during the specified period of time. - This function will attempt to open the link in a new browwser tab (using - `webbrowser` module); it will also be either `print()`ed out (or `display()`ed - out if run inside Jupyter). + In cases where samples cover ranges and not points in time, a sample will + be returned if any part of its range intersects with the requested range. - The function will wait until the user visits the page and authentiactes, or - until a specified time has passed. + As an example, if you have `start_date` as 14:00 and `end_date` at 15:00, + and there is a sample that covers 13:30-14:30, it will be included. - Raises an exception on failure. + Requires an authorized access token. + + Params: + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. + metric: The name of the metric to retrieve samples for. + fulcra_userid: When present, specifies the Fulcra user ID to request data for. Examples: - >>> fulcra.authorize() - Use your browser to log in to Fulcra. If the tab does not open - automatically, visit this URL to authenticate: - https://fulcra.us.auth0.com/activate?user_code=SJZC-GRBW + >>> samples = fulcra.metric_samples( + ... start_time="2023-08-09 07:00:00.000Z", + ... end_time="2023-08-10 07:00:00.000Z", + ... metric="StepCount" + ... ) - When the authorization succeeds, the following will be displayed: + To inspect the first sample: - ``` - Authorization succeeded. - ``` + >>> samples[0] + {'start_date': '2023-08-10T06:05:10.726+00:00', 'end_date': + '2023-08-10T06:05:13.285+00:00', 'extras': None, + 'has_undetermined_duration': False, 'unit': 'count', 'count': 1, + 'uuid': '74983a94-8816-4b95-bbbd-d4108149261a', 'value': 8, + 'source_properties': {'name': 'b c’s iPhone', 'version': '16.6', + 'productType': 'iPhone12,8', 'operatingSystemVersion': [16, 6, 0], + 'sourceBundleIdentifier': + 'com.apple.health.F8872676-6D45-4981-8E14-C009D0AE5F27'}, + 'device_properties': {'name': 'iPhone', 'model': + 'iPhone', 'manufacturer': 'Apple Inc.', + 'hardwareVersion': 'iPhone12,8', + 'softwareVersion': '16.6'}} """ - if ( - self.fulcra_credentials is not None - and not self.fulcra_credentials.is_expired() - ): - if is_notebook: - display(HTML("

Your access token is still valid.

")) - else: - print("Your access token is still valid.") - return - - def prompt(device_code: str, uri: str, code: str): - webbrowser.open_new_tab(uri) - if is_notebook: - display( - HTML( - f'' - + "Use your browser to log in to Fulcra. If " - + " the tab does not open automatically, click here to " - + "log in to Fulcra. The code displayed will " - + f"be: {code}

After you have authorized, " - + "close the browser tab.

" - ) - ) - else: - print( - f""" - Use your browser to log in to Fulcra. If the tab does not open - automatically, visit this URL to authenticate: {uri} - """ - ) - - try: - self.fulcra_credentials = self.oidc.authorize_via_device_flow( - prompt_callback=prompt - ) - if is_notebook: - display(HTML("Authorization succeeded.")) - else: - print("Authorization succeeded.") - except Exception as exc: - raise Exception("Authorization failed. Re-run these calls") from exc + params = {"start_time": start_time, "end_time": end_time, "metric": metric} + resp = self.fulcra_api( + self._v0_data_path("metric_samples", fulcra_userid), query=params + ) + return json.loads(resp) - def get_authorization_code_url( - self, redirect_uri: str, state: Optional[str] = None - ) -> str: + def gmaps_location_updates( + self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + fulcra_source_id: Optional[str] = None, + fulcra_userid: Optional[str] = None, + ) -> List[Dict]: """ - Generates the URL to redirect the user to for the Authorization Code Grant flow. + Return Google Maps geo-location update samples for a user. - The calling application (e.g., a web service) should redirect the user - to this URL. After the user authenticates and authorizes the application, - Auth0 will redirect the user back to the specified `redirect_uri` with - an authorization `code` (and `state` if provided) in the query parameters. + Retrieve the raw Google Maps location update samples for the specified + user during the specified period of time. + + Requires an authorized access token. Params: - redirect_uri: The URL where the user will be redirected after authorization. - This must be registered in your Auth0 application settings. - state: An opaque value used to maintain state between the request and - the callback. It's also used to prevent CSRF attacks. + start_time: The starting timestamp in ISO 8601 format (inclusive). + end_time: The ending timestamp in ISO 8601 format (exclusive). + fulcra_source_id: Optional. When present, specifies the Fulcra source ID to filter results. + fulcra_userid: Optional. When present, specifies the Fulcra user ID to request data for. Returns: - The authorization URL. + A list of dicts, each of which contains the data from a Google Maps location update. """ - return self.oidc.make_authorization_code_url(redirect_uri, state) + params = {"start_time": start_time, "end_time": end_time} + if fulcra_source_id is not None: + params["fulcra_source_id"] = fulcra_source_id - def set_cached_access_token(self, token: str): - """Deprecated. Directly set access token on credentials.""" - self.fulcra_credentials.access_token = token + resp = self.fulcra_api( + self._v0_data_path("gmaps_location_updates", fulcra_userid), query=params + ) + return json.loads(resp) - def set_cached_access_token_expiration(self, expiration: datetime.datetime): - """Deprecated. Directly set access token expiration on credentials.""" - self.fulcra_credentials.access_token_expiration = expiration + def apple_location_updates( + self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + fulcra_userid: Optional[str] = None, + ) -> List[Dict]: + """Retrieve the raw Apple location update samples during the specified + period of time. - def set_cached_refresh_token(self, token: str): - """Deprecated. Directly set refresh token on credentials.""" - self.fulcra_credentials.refresh_token = token + Requires an authorized access token. - def get_cached_access_token(self) -> str | None: - """Deprecated. Return access token from current credentials.""" - if self.fulcra_credentials: - return self.fulcra_credentials.access_token - return None + Params: + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. + fulcra_userid: When present, specifies the Fulcra user ID to request data for. - def get_cached_refresh_token(self) -> str | None: - """Deprecated. Return refresh token from current credentials""" - if self.fulcra_credentials: - return self.fulcra_credentials.refresh_token - return None + Returns: + A list of dicts, each of which contains the data from a location update. - def get_cached_access_token_expiration(self) -> datetime.datetime | None: - """Deprecated. Return access token expiration from credentials""" - if self.fulcra_credentials: - return self.fulcra_credentials.access_token_expiration - return None + Examples: + To retrieve all location updates within a specific hour: - def authorize_with_authorization_code(self, code: str, redirect_uri: str): - """ - Exchanges an authorization code for an access token, refresh token, - and ID token. + >>> updates = fulcra.apple_location_updates( + ... start_time="2023-09-24T20:00:00Z", + ... end_time="2023-09-24T21:10:00Z" + ... ) - This method should be called after the user has been redirected back - to your application's `redirect_uri` with an authorization `code`. + To see the details of the first update: - Params: - code: The authorization code received from Auth0. - redirect_uri: The same `redirect_uri` that was used when requesting - the authorization code. + >>> updates[0] + {'speed': -1, 'horizontal_accuracy_meters': 35, 'longitude_degrees': + -117.15661336566698, 'source_is_simulated_by_software': False, + 'source_is_produced_by_accessory': False, 'latitude_degrees': + 32.706505158026005, 'vertical_accuracy_meters': 3.0130748748779297, + 'course_heading_accuracy_degrees': -1, 'course_heading_degrees': -1, + 'ellipsoidal_altitude_meters': -6.280021667480469, 'floor': 0, + 'speed_accuracy_meters': -1, 'altitude_meters': 29.17388153076172, 'uuid': + 'e80feacc-54e9-414f-86cb-8d6ebd85ea41', 'timestamp': + '2023-09-24T20:39:28.056+00:00'} - Raises: - Exception: If the token exchange fails. """ - try: - self.fulcra_credentials = self.oidc.authorize_via_authorization_code_flow( - code, redirect_uri - ) - if is_notebook: - display(HTML("Authorization succeeded using authorization code.")) - else: - print("Authorization succeeded using authorization code.") - except Exception as exc: - self.fulcra_credentials = None - raise Exception("Failed to exchange authorization code for token.") from exc + params = {"start_time": start_time, "end_time": end_time} + resp = self.fulcra_api( + self._v0_data_path("apple_location_updates", fulcra_userid), query=params + ) + return json.loads(resp) - def refresh_access_token(self) -> bool: + def apple_location_visits( + self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + fulcra_userid: Optional[str] = None, + ) -> List[Dict]: """ - Refreshes the access token using the stored refresh token. + Retrieve the raw Apple location visit samples during the specified + period of time. + + Requires an authorized access token. + + Params: + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. + fulcra_userid: When present, specifies the Fulcra user ID to request data for. Returns: - True if the token was successfully refreshed, False otherwise. + A list of dicts, each of which contains the data from a location visit. - Raises: - Exception: If no refresh token is available. - """ - if ( - self.fulcra_credentials is None - or self.fulcra_credentials.refresh_token is None - ): - raise Exception("No refresh token available to refresh the access token.") + Examples: + To retrieve all location updates within a specific hour: - try: - new_creds = self.oidc.refresh_credentials(self.fulcra_credentials) - except Exception: - return False + >>> visits = fulcra.apple_location_visits( + ... start_time="2023-09-24T20:00:00Z", + ... end_time="2023-09-24T21:10:00Z" + ... ) - # Preserve old refresh token if the server didn't issue a new one - if new_creds.refresh_token is None: - new_creds.refresh_token = self.fulcra_credentials.refresh_token + To see the details of the first update: - self.fulcra_credentials = new_creds + >>> visits[0] + {'longitude_degrees': -117.1224047932943, 'latitude_degrees': + 32.75812770726706, 'arrival_date': '0001-01-01T00:00:00+00:00', + 'departure_date': '2023-09-25T01:42:16.998+00:00', + 'horizontal_accuracy_meters': 32.93262639589646, 'uuid': + '935971dd-0822-49ef-a74f-b09a24d68c3a'} - if self.refresh_callback is not None: - self.refresh_callback(self.fulcra_credentials) - return True + """ + params = {"start_time": start_time, "end_time": end_time} + resp = self.fulcra_api( + self._v0_data_path("apple_location_visits", fulcra_userid), query=params + ) + return json.loads(resp) - def fulcra_api( + def metric_time_series( self, - url_path: str, - method: str = "GET", - query: dict[str, str] | None = None, - data: dict | List[dict] | None = None, - return_http_response: bool = False, - content_type: str = "application/json", - ) -> bytes | http.client.HTTPResponse: + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + metric: str, + sample_rate: float = 60, + replace_nulls: Optional[bool] = False, + fulcra_userid: Optional[str] = None, + calculations: Optional[list[str]] = None, + ) -> pd.DataFrame: """ - Make a call to the given url path (e.g. `/v0/data/metric_time_series?...`) - with the specified access token. + Retrieve time-series data from a single Fulcra metric, covering the + time starting at `start_time` (inclusive) until `end_time` + (exclusive). + + If specified, the `sample_rate` parameter defines the number of + seconds per sample. This value can be smaller than 1. The default + value is 60 (one sample per minute). + + Requires a valid access token. Params: - url_path: The path of the URL to use (e.g. `"/v0/data/..."`) - method: The HTTP method for the request (Default: GET) - query: Key/value pairs of query params - data: Dictionary or list of dictionaries to send as request body - return_http_response: Return a HTTPResponse object instead of bytes (default: False) - content_type: Content-Type header (default: "application/json") + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object + metric: The name of the time-series metric to retrieve + sample_rate: The length (in seconds) of each sample + replace_nulls: When true, replace all NA/null/None values with 0 + fulcra_userid: When present, specifies the Fulcra user ID to request data for. + calculations: When present, specifies additional calculations to perform for each time slice. The current values are: + - `max`: The maximum value for each time window + - `min`: The minimum value for each time window + - `delta`: The delta between the maximum and minimum value for each time window + - `mean`: The mean value for each time window + - `uniques`: The list of unique values for each time window + - `allpoints`: The list of all values for each time window + - `rollingmean`: The rolling mean value for each time window. This mean is calculated relative to the beginning of the requested sample Returns: - The raw response data (as bytes). Raises an exception on failure. - """ + a pandas DataFrame containing the data. For time ranges where data is + missing, the values will be ``. - # Attempt to refresh our access token if it's expired - if self.fulcra_credentials is not None and self.fulcra_credentials.is_expired(): - self.refresh_access_token() + Examples: + To retrieve a dataframe containing the `StepCount` metric: - if self.fulcra_api_is_http: - proto = "http" - else: - proto = "https" + >>> df = fulcra.metric_time_series( + ... start_time = "2024-01-24 00:00:00-08:00", + ... end_time = "2024-01-25 00:00:00-08:00", + ... sample_rate = 1, + ... metric = "StepCount" + ... ) - host = self.fulcra_api_domain + The index of the DataFrame will be the time: - if self.fulcra_api_port: - host = f"{host}:{self.fulcra_api_port}" + >>> df.index + DatetimeIndex(['2024-01-24 08:00:00+00:00', '2024-01-24 08:00:01+00:00', + '2024-01-24 08:00:02+00:00', '2024-01-24 08:00:03+00:00', + '2024-01-24 08:00:04+00:00', '2024-01-24 08:00:05+00:00', + '2024-01-24 08:00:06+00:00', '2024-01-24 08:00:07+00:00', + '2024-01-24 08:00:08+00:00', '2024-01-24 08:00:09+00:00', + ... + '2024-01-25 07:59:50+00:00', '2024-01-25 07:59:51+00:00', + '2024-01-25 07:59:52+00:00', '2024-01-25 07:59:53+00:00', + '2024-01-25 07:59:54+00:00', '2024-01-25 07:59:55+00:00', + '2024-01-25 07:59:56+00:00', '2024-01-25 07:59:57+00:00', + '2024-01-25 07:59:58+00:00', '2024-01-25 07:59:59+00:00'], + dtype='datetime64[us, UTC]', name='time', length=86400, freq=None) - if query: - url_query = urllib.parse.urlencode(query, doseq=True) - else: - url_query = "" + The non-index column(s) in the dataframe will be related to the metric. - url = urllib.parse.urlunparse((proto, host, url_path, "", url_query, "")) - headers = {"Authorization": f"Bearer {self.fulcra_credentials.access_token}"} + >>> df.columns + Index(['step_count'], dtype='object') + """ + params = { + "start_time": start_time, + "end_time": end_time, + "metric": metric, + "output": "arrow", + "samprate": sample_rate, + "replace_nulls": int(replace_nulls), + } + if calculations is not None: + params["calculations"] = calculations - if data: - headers["Content-Type"] = content_type - - # Serialize data based on content type - if content_type == "application/x-jsonl": - # Convert to JSONL (newline-delimited JSON) - if isinstance(data, list): - ds = "\n".join(json.dumps(record) for record in data).encode( - "UTF-8" - ) - else: - # Single dict as JSONL - ds = json.dumps(data).encode("UTF-8") - # Add trailing newline for JSONL - ds += b"\n" - else: - # Standard JSON - ds = json.dumps(data).encode("UTF-8") + resp = self.fulcra_api( + self._v0_data_path("metric_time_series", fulcra_userid), query=params + ) + return pd.read_feather(io.BytesIO(resp)).set_index("time") - headers["Content-Length"] = str(len(ds)) - else: - ds = None + def location_time_series( + self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + change_meters: Optional[float] = None, + sample_rate: int = 900, + look_back: int = 14400, + reverse_geocode: bool = False, + fulcra_userid: Optional[str] = None, + ) -> List[Dict]: + """ + Retrieve a time series of locations that the user was at. This uses + the most precise underlying data sources available at the given time. - req = urllib.request.Request(url=url, data=ds, headers=headers, method=method) + Requires a valid access token. - try: - response = urllib.request.urlopen(req) + Params: + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object + change_meters: when specified, subsequent samples that are fewer than this many meters away will not be included. + sample_rate: The length (in seconds) of each sample + look_back: The maximum number of seconds in the past to look back to find a value for a sample. + reverse_geocode: When true, Fulcra will attempt to reverse geocode the locations and include the details in the results. + fulcra_userid: When present, specifies the Fulcra user ID to request data for. - if return_http_response: - return response + Returns: + A list of samples; each sample represents a location sample. - return response.read() - except HTTPError as exc: - # Handle 303 See Other - follow the redirect with a GET request - if exc.status == 303: - location = exc.headers.get("Location") - if location: - # Extract the path from the location (could be full URL or just path) - parsed = urllib.parse.urlparse(location) - path = parsed.path if parsed.path else location - # Follow the redirect with a GET request - return self.fulcra_api( - path, method="GET", return_http_response=return_http_response - ) - raise + Examples: + >>> locations = fulcra.location_time_series( + ... start_time = "2024-06-06T19:00:00-07:00", + ... end_time = "2024-06-06T20:00:00-07:00", + ... reverse_geocode = True + ... ) + >>> print(pd.DataFrame(locations)) + slice_time lat long time distance_change_m address location_details + 0 2024-06-07T02:00:00+00:00 32.706814 -117.156455 2024-06-07T01:50:10.92+00:00 NaN Petco Park, 100 Park Boulevard, San Diego, CA ... {'annotations': {'DMS': {'lat': '32° 42' 25.87... + 1 2024-06-07T02:15:00+00:00 32.706722 -117.156576 2024-06-07T02:03:56.903+00:00 15.281598 Petco Park, 100 Park Boulevard, San Diego, CA ... {'annotations': {'DMS': {'lat': '32° 42' 25.87... + 2 2024-06-07T02:30:00+00:00 32.706699 -117.156583 2024-06-07T02:22:07.571+00:00 2.588992 Petco Park, 100 Park Boulevard, San Diego, CA ... {'annotations': {'DMS': {'lat': '32° 42' 25.87... + 3 2024-06-07T02:45:00+00:00 32.706699 -117.156583 2024-06-07T02:22:07.571+00:00 0.000000 Petco Park, 100 Park Boulevard, San Diego, CA ... {'annotations': {'DMS': {'lat': '32° 42' 25.87... + """ + params = { + "start_time": start_time, + "end_time": end_time, + "sample_rate": sample_rate, + "look_back": look_back, + "reverse_geocode": reverse_geocode, + } + if change_meters is not None: + params["change_meters"] = change_meters + resp = self.fulcra_api( + self._v0_data_path("location_time_series", fulcra_userid), query=params + ) + return json.loads(resp) - def fulcra_v1_api( - self, data_class: str, data_type: str, params: dict = {} - ) -> bytes: + def location_at_time( + self, + time: Union[str, datetime.datetime], + window_size: int = 14400, + include_after: bool = False, + reverse_geocode: bool = False, + fulcra_userid: Optional[str] = None, + ) -> List[Dict]: """ - Make a call to the v1 API. + Gets the user's location at the specified time. If no sample is + available for the exact time, searches for the closest sample up to + `window_size` seconds back. If `include_after` is true, then also + searches `window_size` seconds forward. Params: - access_token: The access token to authenticate the request with - data_class: The class of data to query (event or metric) - data_type: The data type to query - params: Additional params to add to the query + time: The point in time to get the user's location for. + window_size: The size (in seconds) to look back (and optionally forward) for samples + include_after: When true, a sample that occurs after the requested time may be returned if it is the closest one. + reverse_geocode: When true, Fulcra will attempt to reverse geocode the location and include the details in the results. + fulcra_userid: When present, specifies the Fulcra user ID to request data for. Returns: - The raw response data (as bytes). Raises an exception on failure. - """ - # query_params = urllib.parse.urlencode(params, doseq=True) - return self.fulcra_api(f"/data/v1alpha1/{data_class}/{data_type}", query=params) - - def fulcra_v1_api_path( - self, path: str, params: Optional[dict[str, str]] = None - ) -> bytes: - """ - Make a call to the v1 API using a full path. + A list of dicts; the first dict is the best location sample. - Supports annotation shorthands with UUIDs (e.g., "metric/MomentAnnotation/"). + Examples: - Params: - path: The full path after /data/v1alpha1/ (e.g., "event/MomentAnnotation" or "metric/NumericAnnotation/") - params: Additional params to add to the query + >>> location = fulcra.location_at_time( + ... time = "2024-01-24 00:00:00-08:00", + ... ) - Returns: - The raw response data (as bytes). Raises an exception on failure. + >>> location + [{'speed': 0, 'horizontal_accuracy_meters': 4.848857421534995, 'longitude_degrees': -117.15709954484828, 'latitude_degrees': 32.707083bb994486, 'vertical_accuracy_meters': 3.2114044806616686, 'course_heading_accuracy_degrees': 180, 'course_heading_degrees': 87.05299950647989, 'ellipsoidal_altitude_meters': 32.700060645118356, 'floor': 0, 'speed_accuracy_meters': 0.9654413396512306, 'altitude_meters': 6.15396384336054, 'uuid': '59b2d63b-9b0b-436f-a66f-01129e1b33dd', 'timestamp': '2024-01-24T00:01:45.941+00:00', 'location_source': 'apple_location_update'}] """ - return self.fulcra_api(f"/data/v1alpha1/{path}", query=params if params else {}) + params = { + "time": time, + "window_size": window_size, + "include_after": include_after, + "reverse_geocode": reverse_geocode, + } + resp = self.fulcra_api( + self._v0_data_path("location_at_time", fulcra_userid), query=params + ) + return json.loads(resp) - @staticmethod - def _decode_jwt_claims(token: str) -> dict: + def sleep_cycles( + self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + cycle_gap: Optional[str] = None, + stages: Optional[List[int]] = None, + gap_stages: Optional[List[int]] = None, + clip_to_range: Optional[bool] = True, + fulcra_userid: Optional[str] = None, + ) -> pd.DataFrame: """ - Decode and return the claims (payload) from a JWT without verifying it. + Return sleep cycles summarized from sleep stages. - Args: - token: The JWT to decode. + Processes raw sleep data samples into sleep cycles by finding gaps in the + sleep sample data within a specified time interval. - Returns: - A dict containing all claims from the token's payload. - """ - segs = token.split(".") - if len(segs) < 2: - raise Exception("Token is in an incorrect format.") - payload = segs[1] + "==" # add extra padding to prevent b64decode from breaking - return json.loads(base64.urlsafe_b64decode(payload)) + Requires a valid access token. - def get_token_claims(self) -> dict: - """ - Decode and return all claims from the access token. + Params: + start_time: The starting timestamp in ISO8601 format (inclusive). + end_time: The ending timestamp in ISO8601 format (exclusive). + cycle_gap: Optional. Minimum time interval separating distinct cycles (e.g., "PT2H" for 2 hours). + Defaults to server-side default if not provided. + stages: Optional. Sleep stages to include. Defaults to all stages if not provided. + gap_stages: Optional. Sleep stages to consider as gaps in sleep cycles. + Defaults to server-side default if not provided. + clip_to_range: Optional. Whether to clip the data to the requested date range. + Defaults to True. This is always done when requesting data for + a user other than the authenticated user. + fulcra_userid: Optional. When present, specifies the Fulcra user ID to request data for. Returns: - A dict containing all JWT claims from the access token. + A pandas DataFrame containing the sleep cycle data. """ - if ( - self.fulcra_credentials is None - or self.fulcra_credentials.access_token is None - ): - raise Exception("Authorization must occur before retrieving token claims.") - return self._decode_jwt_claims(self.fulcra_credentials.access_token) + params = { + "start_time": start_time, + "end_time": end_time, + "output": "arrow", + } + if cycle_gap is not None: + params["cycle_gap"] = cycle_gap + if stages is not None: + params["stages"] = stages + if gap_stages is not None: + params["gap_stages"] = gap_stages + if clip_to_range is not None: + params["clip_to_range"] = clip_to_range - def get_id_token_claims(self) -> dict: - """ - Decode and return all claims from the ID token. + resp = self.fulcra_api( + self._v0_data_path("sleep_cycles", fulcra_userid), query=params + ) + return pd.read_feather(io.BytesIO(resp)) - Returns: - A dict containing all JWT claims from the ID token. + def sleep_stages( + self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + cycle_gap: Optional[str] = None, + stages: Optional[List[int]] = None, + gap_stages: Optional[List[int]] = None, + merge_overlapping: Optional[bool] = True, + merge_contiguous: Optional[bool] = True, + clip_to_range: Optional[bool] = True, + fulcra_userid: Optional[str] = None, + ) -> pd.DataFrame: """ - if ( - self.fulcra_credentials is None - or self.fulcra_credentials.id_token is None - ): - raise Exception( - "Authorization must occur before retrieving ID token claims." - ) - return self._decode_jwt_claims(self.fulcra_credentials.id_token) + Return sleep stages derived from raw fulcra metric samples. - def get_authenticated_user_name(self) -> Optional[str]: - """ - Retrieve the display name of the currently authorized user. + Processes raw sleep data samples into non-conflicting sleep stages and + assigns a cycle index by finding gaps in the sleep sample data within a + specified time interval. - The name is read from the `name` claim of the ID token. + If more than one sleep data source is present, sleep stage is determined + based on the priority of the stage (in bed and unknown are deprioritized) + and the start time of the sample (latest takes precedence). + + Requires a valid access token. + + Params: + start_time: The starting timestamp in ISO8601 format (inclusive). + end_time: The ending timestamp in ISO8601 format (exclusive). + cycle_gap: Optional. Minimum time interval separating distinct cycles (e.g., "PT2H" for 2 hours). + Defaults to server-side default if not provided. + stages: Optional. Sleep stages to include. Defaults to all stages if not provided. + gap_stages: Optional. Sleep stages to consider as gaps in sleep cycles. + Defaults to server-side default if not provided. + merge_overlapping: Optional. Whether to merge overlapping stages based on priority and start time. + Defaults to True. + merge_contiguous: Optional. Whether to merge contiguous samples with the same sleep stage. + Defaults to True. + clip_to_range: Optional. Whether to clip the data to the requested date range. + Defaults to True. This is always done when requesting data for + a user other than the authenticated user. + fulcra_userid: Optional. When present, specifies the Fulcra user ID to request data for. Returns: - The authenticated user's name, or None if the ID token has no name - claim. + A pandas DataFrame containing the sleep stage data. """ - claims = self.get_id_token_claims() - return claims.get("name") + params = { + "start_time": start_time, + "end_time": end_time, + "output": "arrow", + } + if cycle_gap is not None: + params["cycle_gap"] = cycle_gap + if stages is not None: + params["stages"] = stages + if gap_stages is not None: + params["gap_stages"] = gap_stages + if merge_overlapping is not None: + params["merge_overlapping"] = merge_overlapping + if merge_contiguous is not None: + params["merge_contiguous"] = merge_contiguous + if clip_to_range is not None: + params["clip_to_range"] = clip_to_range - def get_authenticated_user_email(self) -> Optional[str]: + resp = self.fulcra_api( + self._v0_data_path("sleep_stages", fulcra_userid), query=params + ) + return pd.read_feather(io.BytesIO(resp)) + + def sleep_agg( + self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + cycle_gap: Optional[str] = None, + stages: Optional[List[int]] = None, + gap_stages: Optional[List[int]] = None, + clip_to_range: Optional[bool] = True, + mode: Optional[str] = "end", + period: Optional[str] = "1d", + agg_functions: Optional[List[str]] = None, + tz: Optional[str] = "UTC", + fulcra_userid: Optional[str] = None, + ) -> pd.DataFrame: """ - Retrieve the email address of the currently authorized user. + Return sleep cycles aggregated by a specified period. - The email is read from the `email` claim of the ID token. + Processes raw sleep data samples into aggregated sleep stage durations per period. - Returns: - The authenticated user's email, or None if the ID token has no email - claim. - """ - claims = self.get_id_token_claims() - return claims.get("email") + Requires a valid access token. - def get_fulcra_userid(self) -> str: - """ - Retrieve the currently authorized Fulcra UserID. + Params: + start_time: The starting timestamp in ISO8601 format (inclusive). + end_time: The ending timestamp in ISO8601 format (exclusive). + cycle_gap: Optional. Minimum time interval separating distinct cycles (e.g., "PT2H" for 2 hours). + Defaults to server-side default if not provided. + stages: Optional. Sleep stages to include. Defaults to all stages if not provided. + gap_stages: Optional. Sleep stages to consider as gaps in sleep cycles. + Defaults to server-side default if not provided. + clip_to_range: Optional. Whether to clip the data to the requested date range. + Defaults to True. This is always done when requesting data for + a user other than the authenticated user. + mode: Optional. Whether to use the cycle start or cycle end to assign cycles to periods, + or to split sleep stage intervals at period boundaries. Defaults to "end". + period: Optional. The period start and interval represented with the polars string language + (see https://docs.pola.rs/api/python/dev/reference/expressions/api/polars.Expr.dt.truncate.html). + Defaults to "1d". + agg_functions: Optional. Aggregations to return. Defaults to ["sum"] if not provided. + tz: Optional. IANA time zone to return results in. Defaults to "UTC". + fulcra_userid: Optional. When present, specifies the Fulcra user ID to request data for. Returns: - the Fulcra UserID of the currently-authorized user. + A pandas DataFrame containing the aggregated sleep data. """ - claims = self.get_token_claims() - return claims["fulcradynamics.com/userid"] + params = { + "start_time": start_time, + "end_time": end_time, + "output": "arrow", + } + if cycle_gap is not None: + params["cycle_gap"] = cycle_gap + if stages is not None: + params["stages"] = stages + if gap_stages is not None: + params["gap_stages"] = gap_stages + if clip_to_range is not None: + params["clip_to_range"] = clip_to_range + if mode is not None: + params["mode"] = mode + if period is not None: + params["period"] = period + if agg_functions is not None: + params["agg_functions"] = agg_functions + else: + params["agg_functions"] = ["sum"] # Default as per OpenAPI if not provided + if tz is not None: + params["tz"] = tz - def calendars( + resp = self.fulcra_api( + self._v0_data_path("sleep_agg", fulcra_userid), query=params + ) + return pd.read_feather(io.BytesIO(resp)) + + + def moment_annotations( self, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + source: Optional[str] = None, fulcra_userid: Optional[str] = None, ) -> List[Dict]: """ - Retrieve the list of calendars available in your data store. - - To request the calendars from another user's store, pass their user - ID as the `fulcra_userid` parameter. + Retrieves recorded Moment Annotations, along with any metadata, for the requested time ranges. - Requires an authorized access token. + Requires a valid access token. Params: - fulcra_userid: When present, specifies the Fulcra user ID to request data for. + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object + source: When specified, the full identifier of the source to query records from + fulcra_userid: When present, specifies the Fulcra user ID to request data for Returns: - A list of dicts, each of which represents a calendar. + A list of recorded annotations; each annotation is represented by a dict. - Examples: - To retrieve all calendars from your data store: + """ + params = {} - >>> calendars = fulcra.calendars() - >>> + params["start_time"] = start_time + params["end_time"] = end_time - To inspect the details of a calendar: + if fulcra_userid is not None: + params["fulcra_userid"] = fulcra_userid - >>> calendars[0] - {'calendar_id': '02b761da-46d0-4074-a9c8-406fd0de3adf', 'calendar_name': - 'Birthdays', 'calendar_color': - '[0.5098039507865906,0.5843137502670288,0.686274528503418,1.0]', - 'calendar_source_id': '03da9f61-7b58-4021-8f40-a93548258faf', - 'calendar_source_name': 'Other', 'fulcra_source': 'apple_calendar'} + if "filter" not in params: + params["filter"] = [] + if source is not None: + params["filter"].append(f"source_id:{source}") - """ - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/calendars") + resp = self.fulcra_v1_api("event", "MomentAnnotation", params) return json.loads(resp) - def calendar_events( + def duration_annotations( self, start_time: Union[str, datetime.datetime], end_time: Union[str, datetime.datetime], - calendar_ids: Optional[List[str]] = None, + source: Optional[str] = None, fulcra_userid: Optional[str] = None, ) -> List[Dict]: """ - Retrieve the list of calendar events that occur (at least partially) during the - specified time range. - - To request events from another user's store, pass their user - ID as the `fulcra_userid` parameter. + Retrieves recorded Duration Annotations, along with any metadata, for the requested time ranges. - Requires an authorized access token. + Requires a valid access token. Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. - calendar_ids: - If included, the query results are limited to events that - are on the specified calendars. - fulcra_userid: When present, specifies the Fulcra user ID to request data for. - - Returns: - A list of dicts, each of which contains the data from a calendar event. - - Examples: - To retrieve all calendar events that span a given range of time: - - >>> cal_events = fulcra.calendar_events( - ... start_time = "2023-09-24 07:00:00.000Z", - ... end_time = "2023-09-25 07:00:00.000Z", - ... calendar_ids=["01fb4138-db27-4792-867d-5cfbdc720165"] - ... ) + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object + source: When specified, the full identifier of the source to query records from + fulcra_userid: When present, specifies the Fulcra user ID to request data for - To inspect the details of an event: - >>> cal_events[0] - {'calendar_event_id': 'c409a249-24cd-4c19-b763-3683cc21b9f8', - 'calendar_id': '01fb4138-db27-4792-867d-5cfbdc720165', 'start_date': - '2023-09-24T20:10:00Z', 'end_date': '2023-09-24T21:10:00Z', - 'allow_new_time_proposals': None, 'alarms': - ['19b7692e-7434-44be-a5ba-c8dfa338deb6'], 'availability': 'free', - 'calendar_item_external_identifier': - '7kukuqrfedlm2f9tfbe684r6cqpk9mrk0aqdeoan7jdbr93e7963lagn9uq6pdsbac40', - 'calendar_item_identifier': '22153B27-4BEE-480C-9627-F2EABC698103', - 'event_identifier': - 'EC9D6240-04A7-4869-9D2E-1A7648EA7732:7kukuqrfedlm2f9tfbe684r6cqpk9mrk0aqdeoan7jdbr93e7963lagn9uq6pdsbac40', - 'creation_date': '2023-09-16T23:27:22Z', 'has_alarms': True, - 'has_attendees': True, 'has_notes': True, 'has_recurrence_rules': - False, 'is_all_day': False, 'is_detached': False, 'last_modified_date': - '2023-09-16T23:27:26Z', 'location': 'PETCO Park', 'notes': - 'This event was created from an email you received in Gmail.', - 'occurrence_date': '2023-09-24T20:10:00Z', 'organizer': - '22381502-0af3-487a-820c-e22aa4cae201', 'recurrence_rules': None, - 'status': 'confirmed', 'geolocation': None, 'time_zone': - 'America/Los_Angeles (fixed)', 'title': - 'St. Louis Cardinals at San Diego Padres', 'url': None, - 'extras': {}, 'participants': [{'is_current_user': True, - 'participant_role': 'required', 'participant_type': 'person', - 'participant_status': 'accepted', 'url': 'mailto:cstone@gmail.com', - 'contact_id': '00900185-b290-4f1c-860d-e4433024a943', - 'name': 'cstone@gmail.com'}]} - """ - params = { - "start_time": start_time, - "end_time": end_time, - } - if calendar_ids is not None: - params["calendar_ids"] = calendar_ids - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api( - f"/data/v0/{fulcra_userid}/calendar_events", query=params - ) + Returns: + A list of recorded annotations; each annotation is represented by a dict. + + """ + params = {} + + params["start_time"] = start_time + params["end_time"] = end_time + + if fulcra_userid is not None: + params["fulcra_userid"] = fulcra_userid + + if "filter" not in params: + params["filter"] = [] + + if source is not None: + params["filter"].append(f"source_id:{source}") + + resp = self.fulcra_v1_api("event", "DurationAnnotation", params) return json.loads(resp) - def apple_workouts( + def boolean_annotations( self, start_time: Union[str, datetime.datetime], end_time: Union[str, datetime.datetime], + source: Optional[str] = None, fulcra_userid: Optional[str] = None, ) -> List[Dict]: """ - Retrieve the list of Apple workouts that occurred (at least partially) during - the specified time range. + Retrieves recorded Boolean Annotations, along with any metadata, for the requested time ranges. - Requires an authorized access token. + Requires a valid access token. Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. - fulcra_userid: When present, specifies the Fulcra user ID to request data for. + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object + source: When specified, the full identifier of the source to query records from + fulcra_userid: When present, specifies the Fulcra user ID to request data for Returns: - A list of dicts, each of which contains the data from a workout. + A list of recorded annotations; each annotation is represented by a dict. - Examples: - To retrieve all workouts during a time period: + """ + params = {} - >>> workouts = fulcra.apple_workouts( - ... start_time = "2023-09-21 07:00:00.000Z", - ... end_time = "2023-09-22 07:00:00.000Z" - ... ) + params["start_time"] = start_time + params["end_time"] = end_time - To inspect the details of a workout: + if fulcra_userid is not None: + params["fulcra_userid"] = fulcra_userid - >>> workouts[0] - {'start_date': '2023-09-21T19:18:31.733000Z', 'end_date': - '2023-09-21T19:49:08.773000Z', 'has_undetermined_duration': False, - 'apple_workout_id': '480b25fe-b229-41b9-bf13-7ccf5e2092ec', 'duration': - 1837.0397539138794, 'extras': {'HKTimeZone': 'America/Los_Angeles', - 'HKAverageMETs': '4.37848 kcal/hr·kg' ... } + if "filter" not in params: + params["filter"] = [] - """ - params = {"start_time": start_time, "end_time": end_time} - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/apple_workouts", query=params) + if source is not None: + params["filter"].append(f"source_id:{source}") + + resp = self.fulcra_v1_api("metric", "BooleanAnnotation", params) return json.loads(resp) - def metric_samples( + def numeric_annotations( self, start_time: Union[str, datetime.datetime], end_time: Union[str, datetime.datetime], - metric: str, + source: Optional[str] = None, fulcra_userid: Optional[str] = None, ) -> List[Dict]: """ - Retrieve the raw samples related to the given metric that occurred for the - user during the specified period of time. + Retrieves recorded Numeric Annotations, along with any metadata, for the requested time ranges. - In cases where samples cover ranges and not points in time, a sample will - be returned if any part of its range intersects with the requested range. + Requires a valid access token. - As an example, if you have `start_date` as 14:00 and `end_date` at 15:00, - and there is a sample that covers 13:30-14:30, it will be included. + Params: + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object + source: When specified, the full identifier of the source to query records from + fulcra_userid: When present, specifies the Fulcra user ID to request data for - Requires an authorized access token. + Returns: + A list of recorded annotations; each annotation is represented by a dict. - Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. - metric: The name of the metric to retrieve samples for. - fulcra_userid: When present, specifies the Fulcra user ID to request data for. + """ + params = {} - Examples: + params["start_time"] = start_time + params["end_time"] = end_time - >>> samples = fulcra.metric_samples( - ... start_time="2023-08-09 07:00:00.000Z", - ... end_time="2023-08-10 07:00:00.000Z", - ... metric="StepCount" - ... ) + if fulcra_userid is not None: + params["fulcra_userid"] = fulcra_userid - To inspect the first sample: + if "filter" not in params: + params["filter"] = [] - >>> samples[0] - {'start_date': '2023-08-10T06:05:10.726+00:00', 'end_date': - '2023-08-10T06:05:13.285+00:00', 'extras': None, - 'has_undetermined_duration': False, 'unit': 'count', 'count': 1, - 'uuid': '74983a94-8816-4b95-bbbd-d4108149261a', 'value': 8, - 'source_properties': {'name': 'b c’s iPhone', 'version': '16.6', - 'productType': 'iPhone12,8', 'operatingSystemVersion': [16, 6, 0], - 'sourceBundleIdentifier': - 'com.apple.health.F8872676-6D45-4981-8E14-C009D0AE5F27'}, - 'device_properties': {'name': 'iPhone', 'model': - 'iPhone', 'manufacturer': 'Apple Inc.', - 'hardwareVersion': 'iPhone12,8', - 'softwareVersion': '16.6'}} - """ - params = {"start_time": start_time, "end_time": end_time, "metric": metric} - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/metric_samples", query=params) + if source is not None: + params["filter"].append(f"source_id:{source}") + + resp = self.fulcra_v1_api("metric", "NumericAnnotation", params) return json.loads(resp) - def gmaps_location_updates( + def scale_annotations( self, start_time: Union[str, datetime.datetime], end_time: Union[str, datetime.datetime], - fulcra_source_id: Optional[str] = None, + source: Optional[str] = None, fulcra_userid: Optional[str] = None, ) -> List[Dict]: """ - Return Google Maps geo-location update samples for a user. - - Retrieve the raw Google Maps location update samples for the specified - user during the specified period of time. + Retrieves recorded Scale Annotations, along with any metadata, for the requested time ranges. - Requires an authorized access token. + Requires a valid access token. Params: - start_time: The starting timestamp in ISO 8601 format (inclusive). - end_time: The ending timestamp in ISO 8601 format (exclusive). - fulcra_source_id: Optional. When present, specifies the Fulcra source ID to filter results. - fulcra_userid: Optional. When present, specifies the Fulcra user ID to request data for. + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object + source: When specified, the full identifier of the source to query records from + fulcra_userid: When present, specifies the Fulcra user ID to request data for Returns: - A list of dicts, each of which contains the data from a Google Maps location update. + A list of recorded annotations; each annotation is represented by a dict. + """ - params = {"start_time": start_time, "end_time": end_time} - if fulcra_source_id is not None: - params["fulcra_source_id"] = fulcra_source_id + params = {} - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api( - f"/data/v0/{fulcra_userid}/gmaps_location_updates", query=params - ) + params["start_time"] = start_time + params["end_time"] = end_time + + if fulcra_userid is not None: + params["fulcra_userid"] = fulcra_userid + + if "filter" not in params: + params["filter"] = [] + + if source is not None: + params["filter"].append(f"source_id:{source}") + + resp = self.fulcra_v1_api("metric", "ScaleAnnotation", params) return json.loads(resp) - def apple_location_updates( - self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - fulcra_userid: Optional[str] = None, - ) -> List[Dict]: - """Retrieve the raw Apple location update samples during the specified - period of time. - Requires an authorized access token. +class FulcraAPI(FulcraDataAccessMixin): + """ + The main class for making Fulcra API functions. + + This contains functions for authorizing a token, authenticating HTTP requests, + making calls, and loading data. + """ + + fulcra_credentials: Optional[FulcraCredentials] = None + + def __init__( + self, + oidc_domain: Optional[str] = None, + oidc_client_id: Optional[str] = None, + oidc_scope: Optional[str] = None, + oidc_audience: Optional[str] = None, + access_token: Optional[str] = None, + access_token_expiration: Optional[datetime.datetime] = None, + refresh_token: Optional[str] = None, + credentials: Optional[FulcraCredentials] = None, + refresh_callback: Optional[Callable] = None, + ): + """ + Initializes the FulcraAPI client. Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. - fulcra_userid: When present, specifies the Fulcra user ID to request data for. + oidc_domain: Optional. The OIDC provider domain to use for authentication. + Defaults to FULCRA_OIDC_DOMAIN. + oidc_client_id: Optional. The OIDC client ID to use. + Defaults to FULCRA_OIDC_CLIENT_ID. + oidc_scope: Optional. The OAuth scopes to request. + Defaults to FULCRA_OIDC_SCOPE. + oidc_audience: Optional. The OIDC audience for the token. + Defaults to FULCRA_OIDC_AUDIENCE. + access_token: Optional. An existing access token to use. [Deprecated] + access_token_expiration: Optional. The expiration datetime for the + provided access_token. [Deprecated] + refresh_token: Optional. An existing refresh token to use. [Deprecated] + credentials: Optional. A FulcraCredentials object with credentials to use. + refresh_callback: Optional. A callback function for when the access token is successfully refreshed. + """ - Returns: - A list of dicts, each of which contains the data from a location update. + # New OIDC provider which should replace most of the oidc workflow functionality here + self.oidc = FulcraOIDCProvider( + domain=oidc_domain or FULCRA_OIDC_DOMAIN, + client_id=oidc_client_id or FULCRA_OIDC_CLIENT_ID, + scope=oidc_scope or FULCRA_OIDC_SCOPE, + audience=oidc_audience or FULCRA_OIDC_AUDIENCE, + ) - Examples: - To retrieve all location updates within a specific hour: + self.fulcra_credentials = credentials - >>> updates = fulcra.apple_location_updates( - ... start_time="2023-09-24T20:00:00Z", - ... end_time="2023-09-24T21:10:00Z" - ... ) + audience_url = urllib.parse.urlparse(self.oidc.audience) + self.fulcra_api_domain = audience_url.hostname + self.fulcra_api_is_http = False + if audience_url.scheme == "http": + if self.fulcra_api_domain in ["localhost", "127.0.0.1"]: + self.fulcra_api_is_http = True + else: + raise ValueError("HTTP audience scheme only allowed for localhost") + self.fulcra_api_port = audience_url.port + + # Support for deprecated constructor credential params + if self.fulcra_credentials is None and ( + access_token is not None + or access_token_expiration is not None + or refresh_token is not None + ): + kwargs = {} + if access_token: + kwargs["access_token"] = access_token + if access_token_expiration: + kwargs["access_token_expiration"] = access_token_expiration + if refresh_token: + kwargs["refresh_token"] = refresh_token - To see the details of the first update: + self.fulcra_credentials = FulcraCredentials(**kwargs) - >>> updates[0] - {'speed': -1, 'horizontal_accuracy_meters': 35, 'longitude_degrees': - -117.15661336566698, 'source_is_simulated_by_software': False, - 'source_is_produced_by_accessory': False, 'latitude_degrees': - 32.706505158026005, 'vertical_accuracy_meters': 3.0130748748779297, - 'course_heading_accuracy_degrees': -1, 'course_heading_degrees': -1, - 'ellipsoidal_altitude_meters': -6.280021667480469, 'floor': 0, - 'speed_accuracy_meters': -1, 'altitude_meters': 29.17388153076172, 'uuid': - 'e80feacc-54e9-414f-86cb-8d6ebd85ea41', 'timestamp': - '2023-09-24T20:39:28.056+00:00'} + self.refresh_callback = refresh_callback + def get_token( + self, device_code: str + ) -> Tuple[Optional[str], Optional[datetime.datetime], Optional[str]]: + """ + Deprecated. Polls for an access token using a device code. + Used by the device authorization flow. """ - params = {"start_time": start_time, "end_time": end_time} - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api( - f"/data/v0/{fulcra_userid}/apple_location_updates", query=params - ) - return json.loads(resp) - def apple_location_visits( - self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - fulcra_userid: Optional[str] = None, - ) -> List[Dict]: + try: + creds = self.oidc.get_token( + "urn:ietf:params:oauth:grant-type:device_code", + {"device_code": device_code}, + ) + return ( + creds.access_token, + creds.access_token_expiration, + creds.refresh_token, + ) + except Exception as exc: + return (None, None, None) + + def authorize(self): """ - Retrieve the raw Apple location visit samples during the specified - period of time. + Request a device token, then prompt the user to authorize it. - Requires an authorized access token. + This uses the Device Authorization workflow, which requires the user + to visit a link and confirm that the code shown on the screen matches. - Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. - fulcra_userid: When present, specifies the Fulcra user ID to request data for. + This function will attempt to open the link in a new browwser tab (using + `webbrowser` module); it will also be either `print()`ed out (or `display()`ed + out if run inside Jupyter). - Returns: - A list of dicts, each of which contains the data from a location visit. + The function will wait until the user visits the page and authentiactes, or + until a specified time has passed. + + Raises an exception on failure. Examples: - To retrieve all location updates within a specific hour: - >>> visits = fulcra.apple_location_visits( - ... start_time="2023-09-24T20:00:00Z", - ... end_time="2023-09-24T21:10:00Z" - ... ) + >>> fulcra.authorize() + Use your browser to log in to Fulcra. If the tab does not open + automatically, visit this URL to authenticate: + https://fulcra.us.auth0.com/activate?user_code=SJZC-GRBW - To see the details of the first update: + When the authorization succeeds, the following will be displayed: - >>> visits[0] - {'longitude_degrees': -117.1224047932943, 'latitude_degrees': - 32.75812770726706, 'arrival_date': '0001-01-01T00:00:00+00:00', - 'departure_date': '2023-09-25T01:42:16.998+00:00', - 'horizontal_accuracy_meters': 32.93262639589646, 'uuid': - '935971dd-0822-49ef-a74f-b09a24d68c3a'} + ``` + Authorization succeeded. + ``` + """ + if ( + self.fulcra_credentials is not None + and not self.fulcra_credentials.is_expired() + ): + if is_notebook: + display(HTML("

Your access token is still valid.

")) + else: + print("Your access token is still valid.") + return + def prompt(device_code: str, uri: str, code: str): + webbrowser.open_new_tab(uri) + if is_notebook: + display( + HTML( + f'' + + "Use your browser to log in to Fulcra. If " + + " the tab does not open automatically, click here to " + + "log in to Fulcra. The code displayed will " + + f"be: {code}

After you have authorized, " + + "close the browser tab.

" + ) + ) + else: + print( + f""" + Use your browser to log in to Fulcra. If the tab does not open + automatically, visit this URL to authenticate: {uri} + """ + ) - """ - params = {"start_time": start_time, "end_time": end_time} - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api( - f"/data/v0/{fulcra_userid}/apple_location_visits", query=params - ) - return json.loads(resp) + try: + self.fulcra_credentials = self.oidc.authorize_via_device_flow( + prompt_callback=prompt + ) + if is_notebook: + display(HTML("Authorization succeeded.")) + else: + print("Authorization succeeded.") + except Exception as exc: + raise Exception("Authorization failed. Re-run these calls") from exc - def metric_time_series( - self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - metric: str, - sample_rate: float = 60, - replace_nulls: Optional[bool] = False, - fulcra_userid: Optional[str] = None, - calculations: Optional[list[str]] = None, - ) -> pd.DataFrame: + def get_authorization_code_url( + self, redirect_uri: str, state: Optional[str] = None + ) -> str: """ - Retrieve time-series data from a single Fulcra metric, covering the - time starting at `start_time` (inclusive) until `end_time` - (exclusive). - - If specified, the `sample_rate` parameter defines the number of - seconds per sample. This value can be smaller than 1. The default - value is 60 (one sample per minute). + Generates the URL to redirect the user to for the Authorization Code Grant flow. - Requires a valid access token. + The calling application (e.g., a web service) should redirect the user + to this URL. After the user authenticates and authorizes the application, + Auth0 will redirect the user back to the specified `redirect_uri` with + an authorization `code` (and `state` if provided) in the query parameters. Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object - metric: The name of the time-series metric to retrieve - sample_rate: The length (in seconds) of each sample - replace_nulls: When true, replace all NA/null/None values with 0 - fulcra_userid: When present, specifies the Fulcra user ID to request data for. - calculations: When present, specifies additional calculations to perform for each time slice. The current values are: - - `max`: The maximum value for each time window - - `min`: The minimum value for each time window - - `delta`: The delta between the maximum and minimum value for each time window - - `mean`: The mean value for each time window - - `uniques`: The list of unique values for each time window - - `allpoints`: The list of all values for each time window - - `rollingmean`: The rolling mean value for each time window. This mean is calculated relative to the beginning of the requested sample + redirect_uri: The URL where the user will be redirected after authorization. + This must be registered in your Auth0 application settings. + state: An opaque value used to maintain state between the request and + the callback. It's also used to prevent CSRF attacks. Returns: - a pandas DataFrame containing the data. For time ranges where data is - missing, the values will be ``. - - Examples: - To retrieve a dataframe containing the `StepCount` metric: - - >>> df = fulcra.metric_time_series( - ... start_time = "2024-01-24 00:00:00-08:00", - ... end_time = "2024-01-25 00:00:00-08:00", - ... sample_rate = 1, - ... metric = "StepCount" - ... ) + The authorization URL. + """ + return self.oidc.make_authorization_code_url(redirect_uri, state) - The index of the DataFrame will be the time: + def set_cached_access_token(self, token: str): + """Deprecated. Directly set access token on credentials.""" + self.fulcra_credentials.access_token = token - >>> df.index - DatetimeIndex(['2024-01-24 08:00:00+00:00', '2024-01-24 08:00:01+00:00', - '2024-01-24 08:00:02+00:00', '2024-01-24 08:00:03+00:00', - '2024-01-24 08:00:04+00:00', '2024-01-24 08:00:05+00:00', - '2024-01-24 08:00:06+00:00', '2024-01-24 08:00:07+00:00', - '2024-01-24 08:00:08+00:00', '2024-01-24 08:00:09+00:00', - ... - '2024-01-25 07:59:50+00:00', '2024-01-25 07:59:51+00:00', - '2024-01-25 07:59:52+00:00', '2024-01-25 07:59:53+00:00', - '2024-01-25 07:59:54+00:00', '2024-01-25 07:59:55+00:00', - '2024-01-25 07:59:56+00:00', '2024-01-25 07:59:57+00:00', - '2024-01-25 07:59:58+00:00', '2024-01-25 07:59:59+00:00'], - dtype='datetime64[us, UTC]', name='time', length=86400, freq=None) + def set_cached_access_token_expiration(self, expiration: datetime.datetime): + """Deprecated. Directly set access token expiration on credentials.""" + self.fulcra_credentials.access_token_expiration = expiration - The non-index column(s) in the dataframe will be related to the metric. + def set_cached_refresh_token(self, token: str): + """Deprecated. Directly set refresh token on credentials.""" + self.fulcra_credentials.refresh_token = token - >>> df.columns - Index(['step_count'], dtype='object') - """ - params = { - "start_time": start_time, - "end_time": end_time, - "metric": metric, - "output": "arrow", - "samprate": sample_rate, - "replace_nulls": int(replace_nulls), - } - if calculations is not None: - params["calculations"] = calculations + def get_cached_access_token(self) -> str | None: + """Deprecated. Return access token from current credentials.""" + if self.fulcra_credentials: + return self.fulcra_credentials.access_token + return None - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api( - f"/data/v0/{fulcra_userid}/metric_time_series", query=params - ) - return pd.read_feather(io.BytesIO(resp)).set_index("time") + def get_cached_refresh_token(self) -> str | None: + """Deprecated. Return refresh token from current credentials""" + if self.fulcra_credentials: + return self.fulcra_credentials.refresh_token + return None - def location_time_series( - self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - change_meters: Optional[float] = None, - sample_rate: int = 900, - look_back: int = 14400, - reverse_geocode: bool = False, - fulcra_userid: Optional[str] = None, - ) -> List[Dict]: + def get_cached_access_token_expiration(self) -> datetime.datetime | None: + """Deprecated. Return access token expiration from credentials""" + if self.fulcra_credentials: + return self.fulcra_credentials.access_token_expiration + return None + + def authorize_with_authorization_code(self, code: str, redirect_uri: str): """ - Retrieve a time series of locations that the user was at. This uses - the most precise underlying data sources available at the given time. + Exchanges an authorization code for an access token, refresh token, + and ID token. - Requires a valid access token. + This method should be called after the user has been redirected back + to your application's `redirect_uri` with an authorization `code`. Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object - change_meters: when specified, subsequent samples that are fewer than this many meters away will not be included. - sample_rate: The length (in seconds) of each sample - look_back: The maximum number of seconds in the past to look back to find a value for a sample. - reverse_geocode: When true, Fulcra will attempt to reverse geocode the locations and include the details in the results. - fulcra_userid: When present, specifies the Fulcra user ID to request data for. + code: The authorization code received from Auth0. + redirect_uri: The same `redirect_uri` that was used when requesting + the authorization code. - Returns: - A list of samples; each sample represents a location sample. + Raises: + Exception: If the token exchange fails. + """ + try: + self.fulcra_credentials = self.oidc.authorize_via_authorization_code_flow( + code, redirect_uri + ) + if is_notebook: + display(HTML("Authorization succeeded using authorization code.")) + else: + print("Authorization succeeded using authorization code.") + except Exception as exc: + self.fulcra_credentials = None + raise Exception("Failed to exchange authorization code for token.") from exc - Examples: - >>> locations = fulcra.location_time_series( - ... start_time = "2024-06-06T19:00:00-07:00", - ... end_time = "2024-06-06T20:00:00-07:00", - ... reverse_geocode = True - ... ) - >>> print(pd.DataFrame(locations)) - slice_time lat long time distance_change_m address location_details - 0 2024-06-07T02:00:00+00:00 32.706814 -117.156455 2024-06-07T01:50:10.92+00:00 NaN Petco Park, 100 Park Boulevard, San Diego, CA ... {'annotations': {'DMS': {'lat': '32° 42' 25.87... - 1 2024-06-07T02:15:00+00:00 32.706722 -117.156576 2024-06-07T02:03:56.903+00:00 15.281598 Petco Park, 100 Park Boulevard, San Diego, CA ... {'annotations': {'DMS': {'lat': '32° 42' 25.87... - 2 2024-06-07T02:30:00+00:00 32.706699 -117.156583 2024-06-07T02:22:07.571+00:00 2.588992 Petco Park, 100 Park Boulevard, San Diego, CA ... {'annotations': {'DMS': {'lat': '32° 42' 25.87... - 3 2024-06-07T02:45:00+00:00 32.706699 -117.156583 2024-06-07T02:22:07.571+00:00 0.000000 Petco Park, 100 Park Boulevard, San Diego, CA ... {'annotations': {'DMS': {'lat': '32° 42' 25.87... + def refresh_access_token(self) -> bool: """ - params = { - "start_time": start_time, - "end_time": end_time, - "sample_rate": sample_rate, - "look_back": look_back, - "reverse_geocode": reverse_geocode, - } - if change_meters is not None: - params["change_meters"] = change_meters - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api( - f"/data/v0/{fulcra_userid}/location_time_series", query=params - ) - return json.loads(resp) + Refreshes the access token using the stored refresh token. - def location_at_time( - self, - time: Union[str, datetime.datetime], - window_size: int = 14400, - include_after: bool = False, - reverse_geocode: bool = False, - fulcra_userid: Optional[str] = None, - ) -> List[Dict]: + Returns: + True if the token was successfully refreshed, False otherwise. + + Raises: + Exception: If no refresh token is available. """ - Gets the user's location at the specified time. If no sample is - available for the exact time, searches for the closest sample up to - `window_size` seconds back. If `include_after` is true, then also - searches `window_size` seconds forward. + if ( + self.fulcra_credentials is None + or self.fulcra_credentials.refresh_token is None + ): + raise Exception("No refresh token available to refresh the access token.") - Params: - time: The point in time to get the user's location for. - window_size: The size (in seconds) to look back (and optionally forward) for samples - include_after: When true, a sample that occurs after the requested time may be returned if it is the closest one. - reverse_geocode: When true, Fulcra will attempt to reverse geocode the location and include the details in the results. - fulcra_userid: When present, specifies the Fulcra user ID to request data for. + try: + new_creds = self.oidc.refresh_credentials(self.fulcra_credentials) + except Exception: + return False - Returns: - A list of dicts; the first dict is the best location sample. + # Preserve old refresh token if the server didn't issue a new one + if new_creds.refresh_token is None: + new_creds.refresh_token = self.fulcra_credentials.refresh_token - Examples: + self.fulcra_credentials = new_creds - >>> location = fulcra.location_at_time( - ... time = "2024-01-24 00:00:00-08:00", - ... ) + if self.refresh_callback is not None: + self.refresh_callback(self.fulcra_credentials) - >>> location - [{'speed': 0, 'horizontal_accuracy_meters': 4.848857421534995, 'longitude_degrees': -117.15709954484828, 'latitude_degrees': 32.707083bb994486, 'vertical_accuracy_meters': 3.2114044806616686, 'course_heading_accuracy_degrees': 180, 'course_heading_degrees': 87.05299950647989, 'ellipsoidal_altitude_meters': 32.700060645118356, 'floor': 0, 'speed_accuracy_meters': 0.9654413396512306, 'altitude_meters': 6.15396384336054, 'uuid': '59b2d63b-9b0b-436f-a66f-01129e1b33dd', 'timestamp': '2024-01-24T00:01:45.941+00:00', 'location_source': 'apple_location_update'}] - """ - params = { - "time": time, - "window_size": window_size, - "include_after": include_after, - "reverse_geocode": reverse_geocode, - } - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api( - f"/data/v0/{fulcra_userid}/location_at_time", query=params - ) - return json.loads(resp) + return True - def metrics_catalog( + def fulcra_api( self, - ) -> List[Dict]: + url_path: str, + method: str = "GET", + query: dict[str, str] | None = None, + data: dict | List[dict] | None = None, + return_http_response: bool = False, + content_type: str = "application/json", + authenticated: bool = True, + ) -> bytes | http.client.HTTPResponse: """ - Gets the list of time-series metrics that are available for this user. - These metrics can be passed to the `metric_time_series` function. + Make a call to the given url path (e.g. `/v0/data/metric_time_series?...`) + with the specified access token. + + Params: + url_path: The path of the URL to use (e.g. `"/v0/data/..."`) + method: The HTTP method for the request (Default: GET) + query: Key/value pairs of query params + data: Dictionary or list of dictionaries to send as request body + return_http_response: Return a HTTPResponse object instead of bytes (default: False) + content_type: Content-Type header (default: "application/json") + authenticated: When False, make the request without credentials. + Only valid for public endpoints. (default: True) Returns: - The metrics, including descriptions. + The raw response data (as bytes). Raises an exception on failure. + """ - Examples: + # Attempt to refresh our access token if it's expired + if ( + authenticated + and self.fulcra_credentials is not None + and self.fulcra_credentials.is_expired() + ): + self.refresh_access_token() - >>> metrics = fulcra_client.metrics_catalog() - >>> metrics[0] - {'name': 'AFibBurden', 'description': "A discrete measure of the percentage of time that the user's heart shows signs\n of atrial fibrillation (AFib) during a given monitoring period.", 'unit': 'percent', 'is_time_series': True, 'metric_kind': 'discrete', 'value_column': 'afib_burden'} - >>> metrics[1] - {'name': 'ActiveCaloriesBurned', 'description': 'A cumulative measure of the amount of active energy the user has burned.', 'unit': 'cal', 'is_time_series': True, 'metric_kind': 'cumulative', 'value_column': 'active_calories_burned'} - """ - resp = self.fulcra_api("/data/v0/metrics_catalog") - return json.loads(resp) + if self.fulcra_api_is_http: + proto = "http" + else: + proto = "https" - def v1_catalog( - self, - data_type: str | None = None, - category: str | None = None, - fulcra_userid: str | None = None, - ) -> List[Dict]: - params = {} - if data_type: - params["data_type"] = data_type - if category: - params["category"] = category - if fulcra_userid: - params["fulcra_userid"] = fulcra_userid + host = self.fulcra_api_domain - resp = self.fulcra_api("/data/v1/catalog", query=params) - return json.loads(resp) + if self.fulcra_api_port: + host = f"{host}:{self.fulcra_api_port}" - def v1_catalog_data_type( - self, - data_type: str, - api_version: str, - fulcra_userid: str | None = None, - ) -> Dict: - """ - Get catalog entry for a specific data type and API version, including schema. + if query: + url_query = urllib.parse.urlencode(query, doseq=True) + else: + url_query = "" - Requires a valid access token. + url = urllib.parse.urlunparse((proto, host, url_path, "", url_query, "")) + if authenticated: + headers = { + "Authorization": f"Bearer {self.fulcra_credentials.access_token}" + } + else: + headers = {} - Params: - data_type: The Fulcra data type ID - api_version: API version (e.g., "v1", "v1alpha1") - fulcra_userid: Optional Fulcra user ID to filter by + if data is not None: + headers["Content-Type"] = content_type - Returns: - Dictionary containing catalog entry with schema included + # Serialize data based on content type + if content_type == "application/x-jsonl": + # Convert to JSONL (newline-delimited JSON) + if isinstance(data, list): + ds = "\n".join(json.dumps(record) for record in data).encode( + "UTF-8" + ) + else: + # Single dict as JSONL + ds = json.dumps(data).encode("UTF-8") + # Add trailing newline for JSONL + ds += b"\n" + else: + # Standard JSON + ds = json.dumps(data).encode("UTF-8") - Example: - catalog = client.v1_catalog_data_type("NumericAnnotation", "v1alpha1") - schema = catalog.get("record_spec", {}).get("schema") - """ - params = {} - if fulcra_userid is not None: - params["fulcra_userid"] = fulcra_userid + headers["Content-Length"] = str(len(ds)) + else: + ds = None - uri = f"/data/v1/catalog/{data_type}/{api_version}" - resp = self.fulcra_api(uri, query=params) - return json.loads(resp) + req = urllib.request.Request(url=url, data=ds, headers=headers, method=method) + + try: + response = urllib.request.urlopen(req) + + if return_http_response: + return response + + return response.read() + except HTTPError as exc: + # Handle 303 See Other - follow the redirect with a GET request + if exc.status == 303: + location = exc.headers.get("Location") + if location: + # Extract the path from the location (could be full URL or just path) + parsed = urllib.parse.urlparse(location) + path = parsed.path if parsed.path else location + # Follow the redirect with a GET request + return self.fulcra_api( + path, + method="GET", + return_http_response=return_http_response, + authenticated=authenticated, + ) + raise - def v1_catalog_schema( - self, data_type: str, api_version: str, fulcra_userid: str | None = None - ) -> Dict: + def fulcra_v1_api( + self, data_class: str, data_type: str, params: dict = {} + ) -> bytes: """ - Get the JSON schema for a specific data type and API version. - - Requires a valid access token. + Make a call to the v1 API. Params: - data_type: The Fulcra data type ID - api_version: API version (e.g., "v1", "v1alpha1") - fulcra_userid: Optional Fulcra user ID for the data type + access_token: The access token to authenticate the request with + data_class: The class of data to query (event or metric) + data_type: The data type to query + params: Additional params to add to the query Returns: - Dictionary containing the JSON schema - - Raises: - HTTPError: If schema cannot be fetched (e.g., 404 if not found) - - Example: - schema = client.v1_catalog_schema("NumericAnnotation", "v1alpha1") - required_fields = schema.get("required", []) + The raw response data (as bytes). Raises an exception on failure. """ + # query_params = urllib.parse.urlencode(params, doseq=True) + return self.fulcra_api(f"/data/v1alpha1/{data_class}/{data_type}", query=params) - params = {} - if fulcra_userid is not None: - params["fulcra_userid"] = fulcra_userid - - uri = f"/data/v1/catalog/{data_type}/{api_version}/schema" - resp = self.fulcra_api(uri, query=params) - return json.loads(resp) - - def resolve_data_type( - self, - data_type: str, - api_version: str | None = None, - fulcra_userid: str | None = None, - ) -> List[Dict]: + def fulcra_v1_api_path( + self, path: str, params: Optional[dict[str, str]] = None + ) -> bytes: """ - Resolve a data type to the matching catalog entries for a single user. + Make a call to the v1 API using a full path. - Defaults to the authenticated user ID for disambiguation if fulcra_userid is not provided. - May return more than one entry when the data type exists under multiple API versions; - callers are responsible for deciding whether that ambiguity is acceptable. + Supports annotation shorthands with UUIDs (e.g., "metric/MomentAnnotation/"). Params: - data_type: The data type to resolve - api_version: The API version to use (optional) - fulcra_userid: The Fulcra user ID to use (optional) + path: The full path after /data/v1alpha1/ (e.g., "event/MomentAnnotation" or "metric/NumericAnnotation/") + params: Additional params to add to the query Returns: - A list of matching catalog entries, all belonging to a single user. - - Raises: - ValueError: If no data types are found or they span multiple users. + The raw response data (as bytes). Raises an exception on failure. """ + return self.fulcra_api(f"/data/v1alpha1/{path}", query=params if params else {}) - error_info = [f"for data type {data_type}"] - if api_version is not None: - error_info.append(f"with API version {api_version}") - if fulcra_userid is not None: - error_info.append(f"with user ID {fulcra_userid}") - - try: - # Efficiently fetch a specific data type if specified - if api_version is not None and fulcra_userid is not None: - dt = self.v1_catalog_data_type( - data_type=data_type, - api_version=api_version, - fulcra_userid=fulcra_userid, - ) - return [dt] - else: - data_types = self.v1_catalog( - data_type=data_type, fulcra_userid=fulcra_userid - ) - except HTTPError as exc: - if exc.code == 404: - raise ValueError(f"Type not found {' '.join(error_info)}") - else: - raise - - if api_version is not None: - data_types = [dt for dt in data_types if dt["api_version"] == api_version] - - # Default to the authenticated user ID if None is specified - user_ids = {dt["fulcra_userid"] for dt in data_types} - if fulcra_userid is None and len(user_ids) > 1: - authenticated_user_id = self.get_fulcra_userid() - if authenticated_user_id in user_ids: - data_types = [ - dt - for dt in data_types - if dt["fulcra_userid"] == authenticated_user_id - ] - user_ids = {authenticated_user_id} - - if len(data_types) == 0: - raise ValueError(f"Type not found {' '.join(error_info)}") - - if len(user_ids) > 1: - raise ValueError( - f"Multiple user IDs found {' '.join(error_info)} " - f"({', '.join(sorted(user_ids))})" - ) + def _v0_data_path( + self, operation: str, fulcra_userid: Optional[str] = None + ) -> str: + """ + Build the request path for a v0 data operation on a user's data. - return data_types + Defaults to the authenticated user when `fulcra_userid` is None. + """ + if fulcra_userid is None: + fulcra_userid = self.get_fulcra_userid() + return f"/data/v0/{fulcra_userid}/{operation}" - def create_datashare( - self, - datashare_name: str, - fulcra_data_types: List[str], - allowed_user_ids: List[str], - share_all_data: bool = False, - time_start: Optional[datetime.datetime] = None, - time_end: Optional[datetime.datetime] = None, - ) -> dict: + @staticmethod + def _decode_jwt_claims(token: str) -> dict: """ - Creates a new datashare to share your data with other users. + Decode and return the claims (payload) from a JWT without verifying it. Args: - datashare_name: Name for this datashare - fulcra_data_types: List of data type IDs to share - allowed_user_ids: List of Fulcra user IDs to share with - share_all_data: Whether to share all data types (default: False) - time_start: Optional start time for data range - time_end: Optional end time for data range + token: The JWT to decode. Returns: - A dict containing the created datashare information. - - Examples: - >>> datashare = fulcra_client.create_datashare( - ... datashare_name="My Research Share", - ... fulcra_data_types=["HeartRate", "StepCount"], - ... allowed_user_ids=["a24a9667-c2c6-4bbf-9a0f-4Bej0afcb521"] - ... ) + A dict containing all claims from the token's payload. """ - permissions = [ - {"allowed_fulcra_userid": user_id} for user_id in allowed_user_ids - ] - - # Temporary until we can get the user name from the identity token, - # or until we don't require it in the datashare body - fulcra_user_name = self.get_fulcra_userid() - - datashare_body = { - "datashare_name": datashare_name, - "fulcra_user_name": fulcra_user_name, - "time_start": time_start.isoformat() if time_start else None, - "time_end": time_end.isoformat() if time_end else None, - "fulcra_data_types": fulcra_data_types, - "share_all_data": share_all_data, - "permissions": permissions, - } - - resp = self.fulcra_api( - "/user/v1alpha1/datashares", data=datashare_body, method="POST" - ) - return json.loads(resp) + segs = token.split(".") + if len(segs) < 2: + raise Exception("Token is in an incorrect format.") + payload = segs[1] + "==" # add extra padding to prevent b64decode from breaking + return json.loads(base64.urlsafe_b64decode(payload)) - def update_datashare( - self, - datashare_id: str, - datashare_name: str, - fulcra_data_types: List[str], - allowed_user_ids: List[str], - share_all_data: bool, - time_start: Optional[datetime.datetime], - time_end: Optional[datetime.datetime], - ) -> dict: + def get_token_claims(self) -> dict: """ - Updates an existing datashare with a complete replacement of all fields. - - Note: This method requires all fields to be provided. The CLI handles fetching - current values and building the complete update. Direct API users should fetch - the current share via get_datashares() first if they only want to modify - specific fields. - - Args: - datashare_id: UUID of the datashare to update - datashare_name: Name for the datashare - fulcra_data_types: List of data type IDs to share - allowed_user_ids: List of Fulcra user IDs to share with - share_all_data: Whether to share all data types - time_start: Start time for data range, or None for open-ended - time_end: End time for data range, or None for open-ended + Decode and return all claims from the access token. Returns: - A dict containing the updated datashare information. + A dict containing all JWT claims from the access token. + """ + if ( + self.fulcra_credentials is None + or self.fulcra_credentials.access_token is None + ): + raise Exception("Authorization must occur before retrieving token claims.") + return self._decode_jwt_claims(self.fulcra_credentials.access_token) - Examples: - >>> # Fetch current share first - >>> shares = fulcra_client.get_datashares() - >>> current = next(s for s in shares if s["datashare_id"] == share_id) - >>> - >>> # Update with modified values - >>> updated = fulcra_client.update_datashare( - ... datashare_id=share_id, - ... datashare_name="Updated Research Share", - ... fulcra_data_types=["HeartRate", "StepCount"], - ... allowed_user_ids=current["permissions"], - ... share_all_data=current["share_all_data"], - ... time_start=None, - ... time_end=None - ... ) + def get_id_token_claims(self) -> dict: """ - datashare_body = { - "datashare_name": datashare_name, - "fulcra_data_types": fulcra_data_types, - "share_all_data": share_all_data, - "time_start": time_start.isoformat() if time_start else None, - "time_end": time_end.isoformat() if time_end else None, - "permissions": [ - {"allowed_fulcra_userid": user_id} for user_id in allowed_user_ids - ], - } + Decode and return all claims from the ID token. - resp = self.fulcra_api( - f"/user/v1alpha1/datashare/{datashare_id}", - data=datashare_body, - method="PUT", - ) - return json.loads(resp) + Returns: + A dict containing all JWT claims from the ID token. + """ + if ( + self.fulcra_credentials is None + or self.fulcra_credentials.id_token is None + ): + raise Exception( + "Authorization must occur before retrieving ID token claims." + ) + return self._decode_jwt_claims(self.fulcra_credentials.id_token) - def get_datashares(self) -> List[dict]: + def get_authenticated_user_name(self) -> Optional[str]: """ - Retrieves all datashares created by the authenticated user. + Retrieve the display name of the currently authorized user. - Returns a list of datashares that you have created to share your data - with others. + The name is read from the `name` claim of the ID token. Returns: - A list of datashare dicts. - - Examples: - >>> datashares = fulcra_client.get_datashares() - >>> datashares[0] - {'datashare_id': '...', 'datashare_name': 'My Share', ...} + The authenticated user's name, or None if the ID token has no name + claim. """ - resp = self.fulcra_api("/user/v1alpha1/datashares") - return json.loads(resp) + claims = self.get_id_token_claims() + return claims.get("name") - def delete_datashare(self, datashare_id: str): + def get_authenticated_user_email(self) -> Optional[str]: """ - Deletes a datashare that you created. + Retrieve the email address of the currently authorized user. - Args: - datashare_id: UUID of the datashare to delete + The email is read from the `email` claim of the ID token. - Examples: - >>> fulcra_client.delete_datashare("cf362f80-ef41-4c08-b5e3-b18bd3d1524b") + Returns: + The authenticated user's email, or None if the ID token has no email + claim. """ - self.fulcra_api(f"/user/v1alpha1/datashare/{datashare_id}", method="DELETE") + claims = self.get_id_token_claims() + return claims.get("email") - def data_updates( - self, - start_time: str | datetime.datetime, - end_time: str | datetime.datetime, - ) -> dict: + def get_fulcra_userid(self) -> str: """ - Retrieve a summary of the data that was updated during the specified - time range for the authenticated user. - - This reports the data types that had records processed during the range - (along with the number of records processed for each), as well as any - uploaded files that changed. - - Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. + Retrieve the currently authorized Fulcra UserID. Returns: - A dict with two keys: - - - `data_types`: a dict mapping each data type to the number of records processed for it - - `file_changes`: a list of files that were added, changed, or removed - - Examples: - To see what data was updated during a given range: - - >>> updates = fulcra.data_updates( - ... start_time="2026-02-01 00:00:00Z", - ... end_time="2026-02-03 00:00:00Z" - ... ) - >>> updates["data_types"] - {'StepCount': 412, 'HeartRate': 1875} + the Fulcra UserID of the currently-authorized user. """ - params = { - "start_time": start_time, - "end_time": end_time, - } - resp = self.fulcra_api("/data/v1/updates", query=params) - return json.loads(resp) + claims = self.get_token_claims() + return claims["fulcradynamics.com/userid"] - def get_shared_datasets(self) -> List[Dict]: + def calendars( + self, + fulcra_userid: Optional[str] = None, + ) -> List[Dict]: """ - Retrieves datasets that have been shared with the currently authenticated user + Retrieve the list of calendars available in your data store. - Examples: + To request the calendars from another user's store, pass their user + ID as the `fulcra_userid` parameter. - >>> datasets = fulcra_client.get_shared_datasets() - >>> datasets[0] - {'permission_id': 'cf362f80-ef41-4c08-b5e3-b18bd3d1524b', 'created_at': '2024-08-21T17:52:10.658596Z', 'time_start': None, 'time_end': None, 'fulcra_userid': 'a24a9667-c2c6-4bbf-9a0f-4Bej0afcb521', 'fulcra_user_name': 'John Doe', 'fulcra_user_picture': 'https://lh3.googleusercontent.com/a/ACg8ocL-ggGYjOFq23Dfbf5GohDXbk01AoGmL0gCSbooVBXDgWeTLJk=s47-d', 'datashare_name': 'Provisioned for data analysis'} - """ - resp = self.fulcra_api("/user/v1alpha1/datasets") - return json.loads(resp) + Requires an authorized access token. - def delete_dataset_permission(self, permission_id: str): - """ - Revokes your permission to access a dataset that was shared with you. + Params: + fulcra_userid: When present, specifies the Fulcra user ID to request data for. - Args: - permission_id: UUID of the dataset permission to revoke + Returns: + A list of dicts, each of which represents a calendar. Examples: - >>> fulcra_client.delete_dataset_permission("cf362f80-ef41-4c08-b5e3-b18bd3d1524b") - """ - self.fulcra_api( - f"/user/v1alpha1/dataset/permission/{permission_id}", method="DELETE" - ) + To retrieve all calendars from your data store: - def get_user_info(self) -> Dict: - """ - Return information about the authenticated Fulcra User. + >>> calendars = fulcra.calendars() + >>> - Returns information about the authenticated Fulcra User, including their - preferences such as time zone, calendar ids, etc. + To inspect the details of a calendar: - Returns: - A dict containing user information. + >>> calendars[0] + {'calendar_id': '02b761da-46d0-4074-a9c8-406fd0de3adf', 'calendar_name': + 'Birthdays', 'calendar_color': + '[0.5098039507865906,0.5843137502670288,0.686274528503418,1.0]', + 'calendar_source_id': '03da9f61-7b58-4021-8f40-a93548258faf', + 'calendar_source_name': 'Other', 'fulcra_source': 'apple_calendar'} - Examples: - >>> user_info = fulcra_client.get_user_info() - >>> user_info - {'userid': 'a24a9667-c2c6-4bbf-9a0f-4Bej0afcb521', 'created': '2024-08-20T19:51:09.123456Z', 'preferences': {'timezone': 'America/Los_Angeles'}} """ - resp = self.fulcra_api("/user/v1alpha1/info") - return json.loads(resp) - - def update_user_preferences(self, prefs: Dict): - resp = self.fulcra_api("user/v1alpha1/preferences", method="POST", data=prefs) + if fulcra_userid is None: + fulcra_userid = self.get_fulcra_userid() + resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/calendars") return json.loads(resp) - def sleep_cycles( + def calendar_events( self, start_time: Union[str, datetime.datetime], end_time: Union[str, datetime.datetime], - cycle_gap: Optional[str] = None, - stages: Optional[List[int]] = None, - gap_stages: Optional[List[int]] = None, - clip_to_range: Optional[bool] = True, + calendar_ids: Optional[List[str]] = None, fulcra_userid: Optional[str] = None, - ) -> pd.DataFrame: + ) -> List[Dict]: """ - Return sleep cycles summarized from sleep stages. + Retrieve the list of calendar events that occur (at least partially) during the + specified time range. - Processes raw sleep data samples into sleep cycles by finding gaps in the - sleep sample data within a specified time interval. + To request events from another user's store, pass their user + ID as the `fulcra_userid` parameter. - Requires a valid access token. + Requires an authorized access token. Params: - start_time: The starting timestamp in ISO8601 format (inclusive). - end_time: The ending timestamp in ISO8601 format (exclusive). - cycle_gap: Optional. Minimum time interval separating distinct cycles (e.g., "PT2H" for 2 hours). - Defaults to server-side default if not provided. - stages: Optional. Sleep stages to include. Defaults to all stages if not provided. - gap_stages: Optional. Sleep stages to consider as gaps in sleep cycles. - Defaults to server-side default if not provided. - clip_to_range: Optional. Whether to clip the data to the requested date range. - Defaults to True. This is always done when requesting data for - a user other than the authenticated user. - fulcra_userid: Optional. When present, specifies the Fulcra user ID to request data for. + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. + calendar_ids: + If included, the query results are limited to events that + are on the specified calendars. + fulcra_userid: When present, specifies the Fulcra user ID to request data for. Returns: - A pandas DataFrame containing the sleep cycle data. + A list of dicts, each of which contains the data from a calendar event. + + Examples: + To retrieve all calendar events that span a given range of time: + + >>> cal_events = fulcra.calendar_events( + ... start_time = "2023-09-24 07:00:00.000Z", + ... end_time = "2023-09-25 07:00:00.000Z", + ... calendar_ids=["01fb4138-db27-4792-867d-5cfbdc720165"] + ... ) + + To inspect the details of an event: + >>> cal_events[0] + {'calendar_event_id': 'c409a249-24cd-4c19-b763-3683cc21b9f8', + 'calendar_id': '01fb4138-db27-4792-867d-5cfbdc720165', 'start_date': + '2023-09-24T20:10:00Z', 'end_date': '2023-09-24T21:10:00Z', + 'allow_new_time_proposals': None, 'alarms': + ['19b7692e-7434-44be-a5ba-c8dfa338deb6'], 'availability': 'free', + 'calendar_item_external_identifier': + '7kukuqrfedlm2f9tfbe684r6cqpk9mrk0aqdeoan7jdbr93e7963lagn9uq6pdsbac40', + 'calendar_item_identifier': '22153B27-4BEE-480C-9627-F2EABC698103', + 'event_identifier': + 'EC9D6240-04A7-4869-9D2E-1A7648EA7732:7kukuqrfedlm2f9tfbe684r6cqpk9mrk0aqdeoan7jdbr93e7963lagn9uq6pdsbac40', + 'creation_date': '2023-09-16T23:27:22Z', 'has_alarms': True, + 'has_attendees': True, 'has_notes': True, 'has_recurrence_rules': + False, 'is_all_day': False, 'is_detached': False, 'last_modified_date': + '2023-09-16T23:27:26Z', 'location': 'PETCO Park', 'notes': + 'This event was created from an email you received in Gmail.', + 'occurrence_date': '2023-09-24T20:10:00Z', 'organizer': + '22381502-0af3-487a-820c-e22aa4cae201', 'recurrence_rules': None, + 'status': 'confirmed', 'geolocation': None, 'time_zone': + 'America/Los_Angeles (fixed)', 'title': + 'St. Louis Cardinals at San Diego Padres', 'url': None, + 'extras': {}, 'participants': [{'is_current_user': True, + 'participant_role': 'required', 'participant_type': 'person', + 'participant_status': 'accepted', 'url': 'mailto:cstone@gmail.com', + 'contact_id': '00900185-b290-4f1c-860d-e4433024a943', + 'name': 'cstone@gmail.com'}]} """ params = { "start_time": start_time, "end_time": end_time, - "output": "arrow", } - if cycle_gap is not None: - params["cycle_gap"] = cycle_gap - if stages is not None: - params["stages"] = stages - if gap_stages is not None: - params["gap_stages"] = gap_stages - if clip_to_range is not None: - params["clip_to_range"] = clip_to_range - + if calendar_ids is not None: + params["calendar_ids"] = calendar_ids if fulcra_userid is None: fulcra_userid = self.get_fulcra_userid() + resp = self.fulcra_api( + f"/data/v0/{fulcra_userid}/calendar_events", query=params + ) + return json.loads(resp) + + def metrics_catalog( + self, + ) -> List[Dict]: + """ + Gets the list of time-series metrics that are available for this user. + These metrics can be passed to the `metric_time_series` function. + + Returns: + The metrics, including descriptions. + + Examples: + + >>> metrics = fulcra_client.metrics_catalog() + >>> metrics[0] + {'name': 'AFibBurden', 'description': "A discrete measure of the percentage of time that the user's heart shows signs\n of atrial fibrillation (AFib) during a given monitoring period.", 'unit': 'percent', 'is_time_series': True, 'metric_kind': 'discrete', 'value_column': 'afib_burden'} + >>> metrics[1] + {'name': 'ActiveCaloriesBurned', 'description': 'A cumulative measure of the amount of active energy the user has burned.', 'unit': 'cal', 'is_time_series': True, 'metric_kind': 'cumulative', 'value_column': 'active_calories_burned'} + """ + resp = self.fulcra_api("/data/v0/metrics_catalog") + return json.loads(resp) + + def v1_catalog( + self, + data_type: str | None = None, + category: str | None = None, + fulcra_userid: str | None = None, + ) -> List[Dict]: + params = {} + if data_type: + params["data_type"] = data_type + if category: + params["category"] = category + if fulcra_userid: + params["fulcra_userid"] = fulcra_userid - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/sleep_cycles", query=params) - return pd.read_feather(io.BytesIO(resp)) + resp = self.fulcra_api("/data/v1/catalog", query=params) + return json.loads(resp) - def sleep_stages( + def v1_catalog_data_type( self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - cycle_gap: Optional[str] = None, - stages: Optional[List[int]] = None, - gap_stages: Optional[List[int]] = None, - merge_overlapping: Optional[bool] = True, - merge_contiguous: Optional[bool] = True, - clip_to_range: Optional[bool] = True, - fulcra_userid: Optional[str] = None, - ) -> pd.DataFrame: + data_type: str, + api_version: str, + fulcra_userid: str | None = None, + ) -> Dict: """ - Return sleep stages derived from raw fulcra metric samples. + Get catalog entry for a specific data type and API version, including schema. - Processes raw sleep data samples into non-conflicting sleep stages and - assigns a cycle index by finding gaps in the sleep sample data within a - specified time interval. + Requires a valid access token. - If more than one sleep data source is present, sleep stage is determined - based on the priority of the stage (in bed and unknown are deprioritized) - and the start time of the sample (latest takes precedence). + Params: + data_type: The Fulcra data type ID + api_version: API version (e.g., "v1", "v1alpha1") + fulcra_userid: Optional Fulcra user ID to filter by + + Returns: + Dictionary containing catalog entry with schema included + + Example: + catalog = client.v1_catalog_data_type("NumericAnnotation", "v1alpha1") + schema = catalog.get("record_spec", {}).get("schema") + """ + params = {} + if fulcra_userid is not None: + params["fulcra_userid"] = fulcra_userid + + uri = f"/data/v1/catalog/{data_type}/{api_version}" + resp = self.fulcra_api(uri, query=params) + return json.loads(resp) + + def v1_catalog_schema( + self, data_type: str, api_version: str, fulcra_userid: str | None = None + ) -> Dict: + """ + Get the JSON schema for a specific data type and API version. Requires a valid access token. Params: - start_time: The starting timestamp in ISO8601 format (inclusive). - end_time: The ending timestamp in ISO8601 format (exclusive). - cycle_gap: Optional. Minimum time interval separating distinct cycles (e.g., "PT2H" for 2 hours). - Defaults to server-side default if not provided. - stages: Optional. Sleep stages to include. Defaults to all stages if not provided. - gap_stages: Optional. Sleep stages to consider as gaps in sleep cycles. - Defaults to server-side default if not provided. - merge_overlapping: Optional. Whether to merge overlapping stages based on priority and start time. - Defaults to True. - merge_contiguous: Optional. Whether to merge contiguous samples with the same sleep stage. - Defaults to True. - clip_to_range: Optional. Whether to clip the data to the requested date range. - Defaults to True. This is always done when requesting data for - a user other than the authenticated user. - fulcra_userid: Optional. When present, specifies the Fulcra user ID to request data for. + data_type: The Fulcra data type ID + api_version: API version (e.g., "v1", "v1alpha1") + fulcra_userid: Optional Fulcra user ID for the data type Returns: - A pandas DataFrame containing the sleep stage data. + Dictionary containing the JSON schema + + Raises: + HTTPError: If schema cannot be fetched (e.g., 404 if not found) + + Example: + schema = client.v1_catalog_schema("NumericAnnotation", "v1alpha1") + required_fields = schema.get("required", []) """ - params = { - "start_time": start_time, - "end_time": end_time, - "output": "arrow", - } - if cycle_gap is not None: - params["cycle_gap"] = cycle_gap - if stages is not None: - params["stages"] = stages - if gap_stages is not None: - params["gap_stages"] = gap_stages - if merge_overlapping is not None: - params["merge_overlapping"] = merge_overlapping - if merge_contiguous is not None: - params["merge_contiguous"] = merge_contiguous - if clip_to_range is not None: - params["clip_to_range"] = clip_to_range - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() + params = {} + if fulcra_userid is not None: + params["fulcra_userid"] = fulcra_userid - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/sleep_stages", query=params) - return pd.read_feather(io.BytesIO(resp)) + uri = f"/data/v1/catalog/{data_type}/{api_version}/schema" + resp = self.fulcra_api(uri, query=params) + return json.loads(resp) - def sleep_agg( + def resolve_data_type( self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - cycle_gap: Optional[str] = None, - stages: Optional[List[int]] = None, - gap_stages: Optional[List[int]] = None, - clip_to_range: Optional[bool] = True, - mode: Optional[str] = "end", - period: Optional[str] = "1d", - agg_functions: Optional[List[str]] = None, - tz: Optional[str] = "UTC", - fulcra_userid: Optional[str] = None, - ) -> pd.DataFrame: + data_type: str, + api_version: str | None = None, + fulcra_userid: str | None = None, + ) -> List[Dict]: """ - Return sleep cycles aggregated by a specified period. - - Processes raw sleep data samples into aggregated sleep stage durations per period. + Resolve a data type to the matching catalog entries for a single user. - Requires a valid access token. + Defaults to the authenticated user ID for disambiguation if fulcra_userid is not provided. + May return more than one entry when the data type exists under multiple API versions; + callers are responsible for deciding whether that ambiguity is acceptable. Params: - start_time: The starting timestamp in ISO8601 format (inclusive). - end_time: The ending timestamp in ISO8601 format (exclusive). - cycle_gap: Optional. Minimum time interval separating distinct cycles (e.g., "PT2H" for 2 hours). - Defaults to server-side default if not provided. - stages: Optional. Sleep stages to include. Defaults to all stages if not provided. - gap_stages: Optional. Sleep stages to consider as gaps in sleep cycles. - Defaults to server-side default if not provided. - clip_to_range: Optional. Whether to clip the data to the requested date range. - Defaults to True. This is always done when requesting data for - a user other than the authenticated user. - mode: Optional. Whether to use the cycle start or cycle end to assign cycles to periods, - or to split sleep stage intervals at period boundaries. Defaults to "end". - period: Optional. The period start and interval represented with the polars string language - (see https://docs.pola.rs/api/python/dev/reference/expressions/api/polars.Expr.dt.truncate.html). - Defaults to "1d". - agg_functions: Optional. Aggregations to return. Defaults to ["sum"] if not provided. - tz: Optional. IANA time zone to return results in. Defaults to "UTC". - fulcra_userid: Optional. When present, specifies the Fulcra user ID to request data for. + data_type: The data type to resolve + api_version: The API version to use (optional) + fulcra_userid: The Fulcra user ID to use (optional) Returns: - A pandas DataFrame containing the aggregated sleep data. + A list of matching catalog entries, all belonging to a single user. + + Raises: + ValueError: If no data types are found or they span multiple users. """ - params = { - "start_time": start_time, - "end_time": end_time, - "output": "arrow", - } - if cycle_gap is not None: - params["cycle_gap"] = cycle_gap - if stages is not None: - params["stages"] = stages - if gap_stages is not None: - params["gap_stages"] = gap_stages - if clip_to_range is not None: - params["clip_to_range"] = clip_to_range - if mode is not None: - params["mode"] = mode - if period is not None: - params["period"] = period - if agg_functions is not None: - params["agg_functions"] = agg_functions - else: - params["agg_functions"] = ["sum"] # Default as per OpenAPI if not provided - if tz is not None: - params["tz"] = tz - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() + error_info = [f"for data type {data_type}"] + if api_version is not None: + error_info.append(f"with API version {api_version}") + if fulcra_userid is not None: + error_info.append(f"with user ID {fulcra_userid}") - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/sleep_agg", query=params) - return pd.read_feather(io.BytesIO(resp)) + try: + # Efficiently fetch a specific data type if specified + if api_version is not None and fulcra_userid is not None: + dt = self.v1_catalog_data_type( + data_type=data_type, + api_version=api_version, + fulcra_userid=fulcra_userid, + ) + return [dt] + else: + data_types = self.v1_catalog( + data_type=data_type, fulcra_userid=fulcra_userid + ) + except HTTPError as exc: + if exc.code == 404: + raise ValueError(f"Type not found {' '.join(error_info)}") + else: + raise - def annotations_catalog( + if api_version is not None: + data_types = [dt for dt in data_types if dt["api_version"] == api_version] + + # Default to the authenticated user ID if None is specified + user_ids = {dt["fulcra_userid"] for dt in data_types} + if fulcra_userid is None and len(user_ids) > 1: + authenticated_user_id = self.get_fulcra_userid() + if authenticated_user_id in user_ids: + data_types = [ + dt + for dt in data_types + if dt["fulcra_userid"] == authenticated_user_id + ] + user_ids = {authenticated_user_id} + + if len(data_types) == 0: + raise ValueError(f"Type not found {' '.join(error_info)}") + + if len(user_ids) > 1: + raise ValueError( + f"Multiple user IDs found {' '.join(error_info)} " + f"({', '.join(sorted(user_ids))})" + ) + + return data_types + + def create_datashare( self, - fulcra_userid: Optional[str] = None, - ) -> List[Dict]: + datashare_name: str, + fulcra_data_types: List[str], + allowed_user_ids: List[str], + share_all_data: bool = False, + time_start: Optional[datetime.datetime] = None, + time_end: Optional[datetime.datetime] = None, + ) -> dict: """ - Retrieves a list of all annotations the user has defined, whether or not - there is any data in them. + Creates a new datashare to share your data with other users. - Params: - fulcra_userid: When present, specifies the Fulcra user ID to request data for + Args: + datashare_name: Name for this datashare + fulcra_data_types: List of data type IDs to share + allowed_user_ids: List of Fulcra user IDs to share with + share_all_data: Whether to share all data types (default: False) + time_start: Optional start time for data range + time_end: Optional end time for data range Returns: - A list of defined annotations, including data about type and ID. Use this - information with the annotation-retrieval functions - (`moment_annotations()`, `scale_annotations()`, etc.) to retrieve the - data for a time window. + A dict containing the created datashare information. Examples: - >>> annotations = fulcra.annotations_catalog() - >>> annotations[3] - {'name': 'Energy Level', 'description': 'How much energy do I have right now?', 'annotation_type': 'scale', 'measurement_spec': {'value_type': 'integer', 'metric_kind': 'discrete', 'measurement_type': 'scale', 'unit': None, 'scale': {'min_allowed': 1, 'max_allowed': 5, 'value': 3}}, 'spec': {'default_note': None, 'scale': {'label_mapping': {'mapping_type': 'string', 'string': {'mapping': {'1': 'Very Low', '2': 'Low', '3': 'Medium', '4': 'High', '5': 'Very High'}}}, 'scale_mapping': {'mapping_type': 'emoji', 'color': {'mapping': {'1': '#ff3b30', '2': '#ff9e96', '3': '#8a8a8f', '4': '#99e3ab', '5': '#33c759'}}, 'string': {'mapping': {'1': 'annotation-emoji-1', '2': 'annotation-emoji-2', '3': 'annotation-emoji-3', '4': 'annotation-emoji-4', '5': 'annotation-emoji-5'}}}}}, 'tags': ['cb8e9254-1446-4055-9e3c-4d76335d1be5'], 'fulcra_userid': '315c1b32-5399-40e1-b808-2346da7bf32e', 'id': 'a6b01642-2298-4a49-af6f-0e7edf1cb3cb', 'created_at': '2025-05-22T20:40:51.191044Z', 'updated_at': '2025-05-22T20:40:51.191044Z', 'deleted_at': '2025-05-24T21:52:29.985758Z'} + >>> datashare = fulcra_client.create_datashare( + ... datashare_name="My Research Share", + ... fulcra_data_types=["HeartRate", "StepCount"], + ... allowed_user_ids=["a24a9667-c2c6-4bbf-9a0f-4Bej0afcb521"] + ... ) """ - params = {} + permissions = [ + {"allowed_fulcra_userid": user_id} for user_id in allowed_user_ids + ] - if fulcra_userid is not None: - params["fulcra_userid"] = fulcra_userid + # Temporary until we can get the user name from the identity token, + # or until we don't require it in the datashare body + fulcra_user_name = self.get_fulcra_userid() + + datashare_body = { + "datashare_name": datashare_name, + "fulcra_user_name": fulcra_user_name, + "time_start": time_start.isoformat() if time_start else None, + "time_end": time_end.isoformat() if time_end else None, + "fulcra_data_types": fulcra_data_types, + "share_all_data": share_all_data, + "permissions": permissions, + } - resp = self.fulcra_api("/user/v1alpha1/annotation") + resp = self.fulcra_api( + "/user/v1alpha1/datashares", data=datashare_body, method="POST" + ) return json.loads(resp) - def moment_annotations( + def update_datashare( self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - source: Optional[str] = None, - fulcra_userid: Optional[str] = None, - ) -> List[Dict]: + datashare_id: str, + datashare_name: str, + fulcra_data_types: List[str], + allowed_user_ids: List[str], + share_all_data: bool, + time_start: Optional[datetime.datetime], + time_end: Optional[datetime.datetime], + ) -> dict: """ - Retrieves recorded Moment Annotations, along with any metadata, for the requested time ranges. + Updates an existing datashare with a complete replacement of all fields. - Requires a valid access token. + Note: This method requires all fields to be provided. The CLI handles fetching + current values and building the complete update. Direct API users should fetch + the current share via get_datashares() first if they only want to modify + specific fields. - Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object - source: When specified, the full identifier of the source to query records from - fulcra_userid: When present, specifies the Fulcra user ID to request data for + Args: + datashare_id: UUID of the datashare to update + datashare_name: Name for the datashare + fulcra_data_types: List of data type IDs to share + allowed_user_ids: List of Fulcra user IDs to share with + share_all_data: Whether to share all data types + time_start: Start time for data range, or None for open-ended + time_end: End time for data range, or None for open-ended Returns: - A list of recorded annotations; each annotation is represented by a dict. + A dict containing the updated datashare information. + Examples: + >>> # Fetch current share first + >>> shares = fulcra_client.get_datashares() + >>> current = next(s for s in shares if s["datashare_id"] == share_id) + >>> + >>> # Update with modified values + >>> updated = fulcra_client.update_datashare( + ... datashare_id=share_id, + ... datashare_name="Updated Research Share", + ... fulcra_data_types=["HeartRate", "StepCount"], + ... allowed_user_ids=current["permissions"], + ... share_all_data=current["share_all_data"], + ... time_start=None, + ... time_end=None + ... ) """ - params = {} - - params["start_time"] = start_time - params["end_time"] = end_time - - if fulcra_userid is not None: - params["fulcra_userid"] = fulcra_userid - - if "filter" not in params: - params["filter"] = [] - - if source is not None: - params["filter"].append(f"source_id:{source}") + datashare_body = { + "datashare_name": datashare_name, + "fulcra_data_types": fulcra_data_types, + "share_all_data": share_all_data, + "time_start": time_start.isoformat() if time_start else None, + "time_end": time_end.isoformat() if time_end else None, + "permissions": [ + {"allowed_fulcra_userid": user_id} for user_id in allowed_user_ids + ], + } - resp = self.fulcra_v1_api("event", "MomentAnnotation", params) + resp = self.fulcra_api( + f"/user/v1alpha1/datashare/{datashare_id}", + data=datashare_body, + method="PUT", + ) return json.loads(resp) - def duration_annotations( - self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - source: Optional[str] = None, - fulcra_userid: Optional[str] = None, - ) -> List[Dict]: + def get_datashares(self) -> List[dict]: """ - Retrieves recorded Duration Annotations, along with any metadata, for the requested time ranges. - - Requires a valid access token. + Retrieves all datashares created by the authenticated user. - Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object - source: When specified, the full identifier of the source to query records from - fulcra_userid: When present, specifies the Fulcra user ID to request data for + Returns a list of datashares that you have created to share your data + with others. Returns: - A list of recorded annotations; each annotation is represented by a dict. + A list of datashare dicts. + Examples: + >>> datashares = fulcra_client.get_datashares() + >>> datashares[0] + {'datashare_id': '...', 'datashare_name': 'My Share', ...} """ - params = {} - - params["start_time"] = start_time - params["end_time"] = end_time - - if fulcra_userid is not None: - params["fulcra_userid"] = fulcra_userid + resp = self.fulcra_api("/user/v1alpha1/datashares") + return json.loads(resp) - if "filter" not in params: - params["filter"] = [] + def delete_datashare(self, datashare_id: str): + """ + Deletes a datashare that you created. - if source is not None: - params["filter"].append(f"source_id:{source}") + Args: + datashare_id: UUID of the datashare to delete - resp = self.fulcra_v1_api("event", "DurationAnnotation", params) - return json.loads(resp) + Examples: + >>> fulcra_client.delete_datashare("cf362f80-ef41-4c08-b5e3-b18bd3d1524b") + """ + self.fulcra_api(f"/user/v1alpha1/datashare/{datashare_id}", method="DELETE") - def boolean_annotations( + def data_updates( self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - source: Optional[str] = None, - fulcra_userid: Optional[str] = None, - ) -> List[Dict]: + start_time: str | datetime.datetime, + end_time: str | datetime.datetime, + ) -> dict: """ - Retrieves recorded Boolean Annotations, along with any metadata, for the requested time ranges. + Retrieve a summary of the data that was updated during the specified + time range for the authenticated user. - Requires a valid access token. + This reports the data types that had records processed during the range + (along with the number of records processed for each), as well as any + uploaded files that changed. Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object - source: When specified, the full identifier of the source to query records from - fulcra_userid: When present, specifies the Fulcra user ID to request data for + start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object. + end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object. Returns: - A list of recorded annotations; each annotation is represented by a dict. + A dict with two keys: - """ - params = {} + - `data_types`: a dict mapping each data type to the number of records processed for it + - `file_changes`: a list of files that were added, changed, or removed - params["start_time"] = start_time - params["end_time"] = end_time + Examples: + To see what data was updated during a given range: - if fulcra_userid is not None: - params["fulcra_userid"] = fulcra_userid + >>> updates = fulcra.data_updates( + ... start_time="2026-02-01 00:00:00Z", + ... end_time="2026-02-03 00:00:00Z" + ... ) + >>> updates["data_types"] + {'StepCount': 412, 'HeartRate': 1875} + """ + params = { + "start_time": start_time, + "end_time": end_time, + } + resp = self.fulcra_api("/data/v1/updates", query=params) + return json.loads(resp) - if "filter" not in params: - params["filter"] = [] + def get_shared_datasets(self) -> List[Dict]: + """ + Retrieves datasets that have been shared with the currently authenticated user - if source is not None: - params["filter"].append(f"source_id:{source}") + Examples: - resp = self.fulcra_v1_api("metric", "BooleanAnnotation", params) + >>> datasets = fulcra_client.get_shared_datasets() + >>> datasets[0] + {'permission_id': 'cf362f80-ef41-4c08-b5e3-b18bd3d1524b', 'created_at': '2024-08-21T17:52:10.658596Z', 'time_start': None, 'time_end': None, 'fulcra_userid': 'a24a9667-c2c6-4bbf-9a0f-4Bej0afcb521', 'fulcra_user_name': 'John Doe', 'fulcra_user_picture': 'https://lh3.googleusercontent.com/a/ACg8ocL-ggGYjOFq23Dfbf5GohDXbk01AoGmL0gCSbooVBXDgWeTLJk=s47-d', 'datashare_name': 'Provisioned for data analysis'} + """ + resp = self.fulcra_api("/user/v1alpha1/datasets") return json.loads(resp) - def numeric_annotations( - self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - source: Optional[str] = None, - fulcra_userid: Optional[str] = None, - ) -> List[Dict]: + def delete_dataset_permission(self, permission_id: str): """ - Retrieves recorded Numeric Annotations, along with any metadata, for the requested time ranges. - - Requires a valid access token. + Revokes your permission to access a dataset that was shared with you. - Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object - source: When specified, the full identifier of the source to query records from - fulcra_userid: When present, specifies the Fulcra user ID to request data for + Args: + permission_id: UUID of the dataset permission to revoke - Returns: - A list of recorded annotations; each annotation is represented by a dict. + Examples: + >>> fulcra_client.delete_dataset_permission("cf362f80-ef41-4c08-b5e3-b18bd3d1524b") + """ + self.fulcra_api( + f"/user/v1alpha1/dataset/permission/{permission_id}", method="DELETE" + ) + def get_user_info(self) -> Dict: """ - params = {} + Return information about the authenticated Fulcra User. - params["start_time"] = start_time - params["end_time"] = end_time + Returns information about the authenticated Fulcra User, including their + preferences such as time zone, calendar ids, etc. - if fulcra_userid is not None: - params["fulcra_userid"] = fulcra_userid + Returns: + A dict containing user information. - if "filter" not in params: - params["filter"] = [] + Examples: - if source is not None: - params["filter"].append(f"source_id:{source}") + >>> user_info = fulcra_client.get_user_info() + >>> user_info + {'userid': 'a24a9667-c2c6-4bbf-9a0f-4Bej0afcb521', 'created': '2024-08-20T19:51:09.123456Z', 'preferences': {'timezone': 'America/Los_Angeles'}} + """ + resp = self.fulcra_api("/user/v1alpha1/info") + return json.loads(resp) - resp = self.fulcra_v1_api("metric", "NumericAnnotation", params) + def update_user_preferences(self, prefs: Dict): + resp = self.fulcra_api("user/v1alpha1/preferences", method="POST", data=prefs) return json.loads(resp) - def scale_annotations( + def annotations_catalog( self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - source: Optional[str] = None, fulcra_userid: Optional[str] = None, ) -> List[Dict]: """ - Retrieves recorded Scale Annotations, along with any metadata, for the requested time ranges. - - Requires a valid access token. + Retrieves a list of all annotations the user has defined, whether or not + there is any data in them. Params: - start_time: The start of the time range (inclusive), as an ISO 8601 string or `datetime` object - end_time: The end of the range (exclusive), as an ISO 8601 string or `datetime` object - source: When specified, the full identifier of the source to query records from fulcra_userid: When present, specifies the Fulcra user ID to request data for Returns: - A list of recorded annotations; each annotation is represented by a dict. + A list of defined annotations, including data about type and ID. Use this + information with the annotation-retrieval functions + (`moment_annotations()`, `scale_annotations()`, etc.) to retrieve the + data for a time window. + Examples: + >>> annotations = fulcra.annotations_catalog() + >>> annotations[3] + {'name': 'Energy Level', 'description': 'How much energy do I have right now?', 'annotation_type': 'scale', 'measurement_spec': {'value_type': 'integer', 'metric_kind': 'discrete', 'measurement_type': 'scale', 'unit': None, 'scale': {'min_allowed': 1, 'max_allowed': 5, 'value': 3}}, 'spec': {'default_note': None, 'scale': {'label_mapping': {'mapping_type': 'string', 'string': {'mapping': {'1': 'Very Low', '2': 'Low', '3': 'Medium', '4': 'High', '5': 'Very High'}}}, 'scale_mapping': {'mapping_type': 'emoji', 'color': {'mapping': {'1': '#ff3b30', '2': '#ff9e96', '3': '#8a8a8f', '4': '#99e3ab', '5': '#33c759'}}, 'string': {'mapping': {'1': 'annotation-emoji-1', '2': 'annotation-emoji-2', '3': 'annotation-emoji-3', '4': 'annotation-emoji-4', '5': 'annotation-emoji-5'}}}}}, 'tags': ['cb8e9254-1446-4055-9e3c-4d76335d1be5'], 'fulcra_userid': '315c1b32-5399-40e1-b808-2346da7bf32e', 'id': 'a6b01642-2298-4a49-af6f-0e7edf1cb3cb', 'created_at': '2025-05-22T20:40:51.191044Z', 'updated_at': '2025-05-22T20:40:51.191044Z', 'deleted_at': '2025-05-24T21:52:29.985758Z'} """ params = {} - params["start_time"] = start_time - params["end_time"] = end_time - if fulcra_userid is not None: params["fulcra_userid"] = fulcra_userid - if "filter" not in params: - params["filter"] = [] - - if source is not None: - params["filter"].append(f"source_id:{source}") - - resp = self.fulcra_v1_api("metric", "ScaleAnnotation", params) + resp = self.fulcra_api("/user/v1alpha1/annotation", query=params) return json.loads(resp) def tags(self) -> list[dict[str, str]]: @@ -2314,3 +2375,485 @@ def restore_file(self, file_id: str): return json.loads( self.fulcra_api(f"/input/v1/file_upload/{file_id}/restore", method="POST") ) + + def get_groups(self, subscribed_only: bool = False) -> List[dict]: + """ + Retrieves a list of data groups. + + By default, returns all public groups. When `subscribed_only` is True, + returns only the groups that you have joined; each of these also + includes your `participant_id` and `joined_at` values. + + Args: + subscribed_only: When True, return only groups you have joined + (default: False) + + Returns: + A list of groups; each is represented by a dict. + + Examples: + >>> groups = fulcra_client.get_groups(subscribed_only=True) + >>> groups[0]["title"] + 'My Research Study' + """ + query = {} + if subscribed_only: + query["subscribed_only"] = "true" + resp = self.fulcra_api("/user/v1alpha1/pool", query=query) + return json.loads(resp) + + def get_group(self, group_id: str) -> dict: + """ + Retrieves the description of a single data group. + + Args: + group_id: UUID of the group + + Returns: + The group, represented by a dict. + """ + resp = self.fulcra_api(f"/user/v1alpha1/pool/{group_id}") + return json.loads(resp) + + def create_group( + self, + title: str, + responsible_entity: str, + description: str, + fulcra_data_types: List[str], + group_url: str, + time_start: Optional[datetime.datetime] = None, + time_end: Optional[datetime.datetime] = None, + detail_markdown: Optional[str] = None, + agreement_markdown: Optional[str] = None, + withdraw_markdown: Optional[str] = None, + header_image_url: Optional[str] = None, + preview_image_url: Optional[str] = None, + friendly_id: Optional[str] = None, + ) -> dict: + """ + Creates a new data group that other Fulcra users can join. + + When a participant joins the group, they share read-only access to + their data (limited to `fulcra_data_types` and the given time range) + with you until they leave the group. + + Most group parameters are immutable after creation; for example, the group + owner can't later change the conditions of the data you agreed to share when + you join. + + Args: + title: Title of the group + responsible_entity: The person or organization responsible for + the group + description: Description of the group + fulcra_data_types: List of Fulcra data types that participants + will share + group_url: URL of the webapp associated with this group + time_start: Optional start of the shared data time range. Must + include a timezone offset. + time_end: Optional end of the shared data time range. Must include + a timezone offset. + detail_markdown: Optional markdown shown on the group's detail view + agreement_markdown: Optional markdown shown when a user joins + withdraw_markdown: Optional markdown shown when a user leaves + header_image_url: Optional URL of the group's header image + preview_image_url: Optional URL of the group's preview image + friendly_id: Optional human-friendly identifier for the group + + Returns: + The created group, represented by a dict. + + Examples: + >>> group = fulcra_client.create_group( + ... title="Step Challenge", + ... responsible_entity="Fulcra Dynamics", + ... description="A month-long step challenge.", + ... fulcra_data_types=["StepCount"], + ... group_url="https://example.com/challenge", + ... ) + """ + for parameter_name, value in ( + ("time_start", time_start), + ("time_end", time_end), + ): + if value is not None and ( + value.tzinfo is None or value.utcoffset() is None + ): + raise ValueError(f"{parameter_name} must include a timezone offset") + + group_body = { + "title": title, + "is_public": False, + "responsible_entity": responsible_entity, + "description": description, + "time_start": time_start.isoformat() if time_start else None, + "time_end": time_end.isoformat() if time_end else None, + "fulcra_data_types": fulcra_data_types, + "pool_url": group_url, + "detail_markdown": detail_markdown, + "agreement_markdown": agreement_markdown, + "withdraw_markdown": withdraw_markdown, + "header_image_url": header_image_url, + "preview_image_url": preview_image_url, + "friendly_id": friendly_id, + } + resp = self.fulcra_api("/user/v1alpha1/pool", data=group_body, method="POST") + return json.loads(resp)["pool"] + + def update_group( + self, + group_id: str, + description: Optional[str] = UNSET, + header_image_url: Optional[str] = UNSET, + preview_image_url: Optional[str] = UNSET, + view_description: Optional[dict] = UNSET, + ) -> dict: + """ + Updates the editable fields of a group that you own. + + Only the fields listed here can be changed after creation; all other + group parameters are immutable. Fields that are not passed are not + modified. Passing None explicitly clears the field + (`header_image_url`, `preview_image_url`, and `view_description` + only; the server does not allow clearing `description`). + + Args: + group_id: UUID of the group to update + description: New description for the group + header_image_url: New URL of the group's header image, or None + to clear it + preview_image_url: New URL of the group's preview image, or None + to clear it + view_description: New dict describing the group's view, or None + to clear it + + Returns: + The updated group, represented by a dict. + + Examples: + To change a group's description (other fields are untouched): + + >>> group = fulcra_client.update_group( + ... group_id="cf362f80-ef41-4c08-b5e3-b18bd3d1524b", + ... description="A month-long step challenge, now with prizes.", + ... ) + + To set a header image and clear the preview image in one call: + + >>> group = fulcra_client.update_group( + ... group_id="cf362f80-ef41-4c08-b5e3-b18bd3d1524b", + ... header_image_url="https://example.com/header.png", + ... preview_image_url=None, + ... ) + >>> group["preview_image_url"] is None + True + """ + group_body = { + k: v + for k, v in { + "description": description, + "header_image_url": header_image_url, + "preview_image_url": preview_image_url, + "view_description": view_description, + }.items() + if v is not UNSET + } + resp = self.fulcra_api( + f"/user/v1alpha1/pool/{group_id}", data=group_body, method="PUT" + ) + return json.loads(resp) + + def delete_group(self, group_id: str): + """ + Deletes a group that you own. + + Args: + group_id: UUID of the group to delete + """ + self.fulcra_api(f"/user/v1alpha1/pool/{group_id}", method="DELETE") + + def join_group(self, group_id: str) -> dict: + """ + Joins a data group as a participant. + + Joining shares read-only access to your data (limited to the group's + data types and time range) with the group's owner until you leave. + The owner sees you only as the returned anonymized `participant_id`, + never your Fulcra UserID. + + Args: + group_id: UUID of the group to join + + Returns: + A dict containing your `participant_id` and `joined_at` time. + """ + resp = self.fulcra_api( + f"/user/v1alpha1/pool/{group_id}/membership", method="POST" + ) + return json.loads(resp) + + def leave_group(self, group_id: str): + """ + Leaves a data group, revoking the owner's access to your data. + + Args: + group_id: UUID of the group to leave + """ + self.fulcra_api(f"/user/v1alpha1/pool/{group_id}/membership", method="DELETE") + + def get_group_participants(self, group_id: str) -> List[str]: + """ + Retrieves the participant IDs of a group that you own. + + Participant IDs are anonymized UUIDs that are only meaningful within + this group; they do not reveal participants' Fulcra UserIDs. + + Args: + group_id: UUID of the group + + Returns: + A list of participant ID strings. + """ + resp = self.fulcra_api(f"/user/v1alpha1/pool/{group_id}/participants") + return json.loads(resp) + + def get_group_participant_metadata( + self, group_id: str, participant_id: str + ) -> dict: + """ + Retrieves the metadata object for a participant in a group you own. + + Args: + group_id: UUID of the group + participant_id: Participant ID within the group + + Returns: + The participant's metadata, represented by a dict. + """ + resp = self.fulcra_api( + f"/user/v1alpha1/pool/{group_id}/participants/{participant_id}/metadata" + ) + return json.loads(resp) + + def set_group_participant_metadata( + self, group_id: str, participant_id: str, metadata: dict + ): + """ + Replaces the metadata object for a participant in a group you own. + + This overwrites the participant's entire metadata object; to modify + individual values, use `update_group_participant_metadata` instead. + + Args: + group_id: UUID of the group + participant_id: Participant ID within the group + metadata: The new metadata object + """ + self.fulcra_api( + f"/user/v1alpha1/pool/{group_id}/participants/{participant_id}/metadata", + data=metadata, + method="PUT", + ) + + def update_group_participant_metadata( + self, group_id: str, participant_id: str, values: dict + ): + """ + Updates some values on a participant's metadata in a group you own. + + The given values are merged into the participant's existing metadata + object; other values are left unchanged. To replace the entire + object, use `set_group_participant_metadata` instead. + + Args: + group_id: UUID of the group + participant_id: Participant ID within the group + values: The metadata values to set + """ + self.fulcra_api( + f"/user/v1alpha1/pool/{group_id}/participants/{participant_id}/metadata_values", + data=values, + method="POST", + ) + + def get_group_jwks(self) -> dict: + """ + Retrieves the group public keys as a JWKS. + + Group webapps can use these keys to validate the participant JWTs + that Context sends when authenticating requests. + + Requires a valid access token. (The underlying route defines no + authentication requirement, but the API gateway currently rejects + unauthenticated requests.) + + Returns: + The JWKS, represented by a dict. + """ + resp = self.fulcra_api("/user/v1alpha1/pool/.well-known/jwks.json") + return json.loads(resp) + + def group_participant( + self, group_id: str, participant_id: str + ) -> "FulcraGroupParticipant": + """ + Returns an accessor for the data that a group participant shares + with you. + + The returned object has the same data-access methods as this client + (`metric_time_series`, `metric_samples`, `sleep_agg`, ...), scoped to + the participant's shared data, along with the participant metadata + operations. + + Args: + group_id: UUID of a group that you own + participant_id: Participant ID within the group + + Returns: + A `FulcraGroupParticipant` accessor. + + Examples: + >>> for pid in fulcra_client.get_group_participants(group_id): + ... participant = fulcra_client.group_participant(group_id, pid) + ... df = participant.metric_time_series( + ... start_time="2026-07-01T00:00:00Z", + ... end_time="2026-07-02T00:00:00Z", + ... metric="StepCount", + ... ) + """ + return FulcraGroupParticipant(self, group_id, participant_id) + + +class FulcraGroupParticipant(FulcraDataAccessMixin): + """ + Accessor for the data that a group participant shares with the group + owner. + + Obtain an instance via `FulcraAPI.group_participant`. The data-access + methods (`metric_time_series`, `metric_samples`, `moment_annotations`, + `sleep_agg`, ...) have the same parameters and return types as their + `FulcraAPI` counterparts, but are scoped to the participant's shared + data; requests outside the group's data types or time range are + rejected by the server. The `fulcra_userid` parameter of these methods + cannot be used here. + + Requests are authenticated by the `FulcraAPI` client that created this + accessor; only the group's owner can access participant data. + """ + + def __init__(self, client: FulcraAPI, group_id: str, participant_id: str): + self.client = client + self.group_id = group_id + self.participant_id = participant_id + + def fulcra_api( + self, + url_path: str, + method: str = "GET", + query: Optional[dict] = None, + data: Optional[Union[dict, List[dict]]] = None, + return_http_response: bool = False, + content_type: str = "application/json", + ) -> Any: + """ + Make an authenticated request to the Fulcra API, using the parent + client's credentials. + """ + return self.client.fulcra_api( + url_path, + method=method, + query=query, + data=data, + return_http_response=return_http_response, + content_type=content_type, + ) + + def _v0_data_path( + self, operation: str, fulcra_userid: Optional[str] = None + ) -> str: + """ + Build the request path for a v0 data operation on the participant's + shared data. + """ + if fulcra_userid is not None: + raise ValueError( + "fulcra_userid cannot be specified when accessing group " + "participant data" + ) + return ( + f"/data/v0/pool/{self.group_id}/participant" + f"/{self.participant_id}/{operation}" + ) + + def _v1_pool_params(self, params: Optional[dict]) -> dict: + """ + Return v1 API query params scoped to the participant's shared data. + """ + params = dict(params) if params else {} + if params.get("fulcra_userid") is not None: + raise ValueError( + "fulcra_userid cannot be specified when accessing group " + "participant data" + ) + params["pool_id"] = self.group_id + params["participant_id"] = self.participant_id + return params + + def fulcra_v1_api( + self, data_class: str, data_type: str, params: dict = {} + ) -> bytes: + """ + Make a call to the v1 API, scoped to the participant's shared data. + """ + return self.client.fulcra_v1_api( + data_class, data_type, self._v1_pool_params(params) + ) + + def fulcra_v1_api_path( + self, path: str, params: Optional[dict[str, str]] = None + ) -> bytes: + """ + Make a call to the v1 API using a full path, scoped to the + participant's shared data. + """ + return self.client.fulcra_v1_api_path(path, self._v1_pool_params(params)) + + def get_metadata(self) -> dict: + """ + Retrieves this participant's metadata object. + + Returns: + The participant's metadata, represented by a dict. + """ + return self.client.get_group_participant_metadata( + self.group_id, self.participant_id + ) + + def set_metadata(self, metadata: dict): + """ + Replaces this participant's entire metadata object. + + To modify individual values instead, use `update_metadata`. + + Args: + metadata: The new metadata object + """ + self.client.set_group_participant_metadata( + self.group_id, self.participant_id, metadata + ) + + def update_metadata(self, values: dict): + """ + Updates some values on this participant's metadata. + + The given values are merged into the existing metadata object; other + values are left unchanged. To replace the entire object, use + `set_metadata`. + + Args: + values: The metadata values to set + """ + self.client.update_group_participant_metadata( + self.group_id, self.participant_id, values + ) diff --git a/fulcra_api/credentials.py b/fulcra_api/credentials.py index da3e2ef..1936df1 100644 --- a/fulcra_api/credentials.py +++ b/fulcra_api/credentials.py @@ -1,5 +1,5 @@ import json -from dataclasses import dataclass +from dataclasses import dataclass, fields from datetime import datetime from typing import Optional, Self @@ -59,4 +59,9 @@ def from_json(cls, data: str | bytes) -> Self: o["id_token_expiration"] ) - return FulcraCredentials(**o) + # Ignore keys written by newer versions of this library so that a + # credentials file is always readable by older releases. + known_fields = {f.name for f in fields(cls)} + return FulcraCredentials( + **{k: v for k, v in o.items() if k in known_fields} + ) diff --git a/mkdocs.yml b/mkdocs.yml index 4a1750e..4a7adef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,8 @@ plugins: handlers: python: paths: [.] + options: + inherited_members: true nav: diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 0000000..1e1fa83 --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,482 @@ +import datetime +import urllib.request +from typing import List +from urllib.error import HTTPError + +import pytest + +from fulcra_api.core import FulcraAPI, FulcraGroupParticipant +from fulcra_api.credentials import FulcraCredentials + + +@pytest.fixture(scope="session") +def fulcra_client() -> FulcraAPI: + fulcra = FulcraAPI() + fulcra.authorize() + return fulcra + + +# +# Offline tests (no authorization required) +# + + +def offline_client() -> FulcraAPI: + return FulcraAPI( + credentials=FulcraCredentials( + access_token="fake-token", + access_token_expiration=datetime.datetime.now() + + datetime.timedelta(hours=1), + ) + ) + + +def test_group_participant_paths(): + client = offline_client() + participant = client.group_participant("gid-123", "pid-456") + assert isinstance(participant, FulcraGroupParticipant) + assert ( + participant._v0_data_path("metric_samples") + == "/data/v0/pool/gid-123/participant/pid-456/metric_samples" + ) + with pytest.raises(ValueError): + participant._v0_data_path("metric_samples", "some-user") + assert ( + client._v0_data_path("sleep_agg", "user-789") == "/data/v0/user-789/sleep_agg" + ) + + +def test_group_participant_method_surface(): + participant = offline_client().group_participant("gid", "pid") + for name in [ + "metric_time_series", + "metric_samples", + "apple_workouts", + "location_at_time", + "location_time_series", + "gmaps_location_updates", + "apple_location_updates", + "apple_location_visits", + "sleep_stages", + "sleep_cycles", + "sleep_agg", + "moment_annotations", + "duration_annotations", + "boolean_annotations", + "numeric_annotations", + "scale_annotations", + "get_metadata", + "set_metadata", + "update_metadata", + ]: + assert callable(getattr(participant, name)) + # Operations without pool data routes must not exist on the accessor + for name in [ + "calendars", + "calendar_events", + "annotations_catalog", + "create_group", + "authorize", + ]: + assert not hasattr(participant, name) + + +def test_group_participant_v1_params(): + client = offline_client() + participant = client.group_participant("gid-123", "pid-456") + + captured = {} + + def fake_fulcra_api(url_path, method="GET", query=None, **kwargs): + captured["path"] = url_path + captured["query"] = query + return b"[]" + + client.fulcra_api = fake_fulcra_api + + participant.moment_annotations("2026-07-01", "2026-07-02") + assert captured["path"] == "/data/v1alpha1/event/MomentAnnotation" + assert captured["query"]["pool_id"] == "gid-123" + assert captured["query"]["participant_id"] == "pid-456" + + participant.fulcra_v1_api_path( + "metric/NumericAnnotation", {"start_time": "a", "end_time": "b"} + ) + assert captured["path"] == "/data/v1alpha1/metric/NumericAnnotation" + assert captured["query"]["pool_id"] == "gid-123" + + with pytest.raises(ValueError): + participant.moment_annotations( + "2026-07-01", "2026-07-02", fulcra_userid="someone-else" + ) + + +def test_empty_body_is_sent(monkeypatch): + """Regression test: data={} must send an empty JSON body, not no body.""" + client = offline_client() + captured = {} + + def fake_urlopen(req): + captured["data"] = req.data + captured["content_type"] = req.headers.get("Content-type") + + class Resp: + def read(self): + return b"{}" + + return Resp() + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + client.set_group_participant_metadata("gid", "pid", {}) + assert captured["data"] == b"{}" + assert captured["content_type"] == "application/json" + + client.fulcra_api("/user/v1alpha1/pool") + assert captured["data"] is None + assert captured["content_type"] is None + + +def test_create_group_is_always_private(): + """Groups created through this library are never publicly listed.""" + import inspect + + from fulcra_api.cli.groups import create + + client = offline_client() + captured = {} + + def fake_fulcra_api(url_path, method="GET", data=None, **kwargs): + captured["data"] = data + return b'{"pool": {}}' + + client.fulcra_api = fake_fulcra_api + + client.create_group( + title="t", + responsible_entity="r", + description="d", + fulcra_data_types=["StepCount"], + group_url="https://example.com/", + ) + assert captured["data"]["is_public"] is False + + # Neither the API nor the CLI may expose a way to ask for a public group. + assert "is_public" not in inspect.signature(client.create_group).parameters + cli_option_names = { + opt for param in create.params for opt in getattr(param, "opts", []) + } + assert "--public" not in cli_option_names + + +@pytest.mark.parametrize("parameter_name", ["time_start", "time_end"]) +def test_create_group_requires_timezone(parameter_name): + """Group access boundaries must carry an explicit timezone offset.""" + client = offline_client() + + def unexpected_request(*args, **kwargs): + raise AssertionError("create_group sent a request with a naive datetime") + + client.fulcra_api = unexpected_request + kwargs = { + "title": "t", + "responsible_entity": "r", + "description": "d", + "fulcra_data_types": ["StepCount"], + "group_url": "https://example.com/", + parameter_name: datetime.datetime(2026, 7, 1), + } + + with pytest.raises( + ValueError, match=f"{parameter_name} must include a timezone offset" + ): + client.create_group(**kwargs) + + +def test_create_group_accepts_timezone_aware_boundaries(): + client = offline_client() + captured = {} + + def fake_fulcra_api(url_path, method="GET", data=None, **kwargs): + captured["data"] = data + return b'{"pool": {}}' + + client.fulcra_api = fake_fulcra_api + offset = datetime.timezone(datetime.timedelta(hours=-7)) + client.create_group( + title="t", + responsible_entity="r", + description="d", + fulcra_data_types=["StepCount"], + group_url="https://example.com/", + time_start=datetime.datetime(2026, 7, 1, tzinfo=offset), + time_end=datetime.datetime(2026, 7, 2, tzinfo=offset), + ) + + assert captured["data"]["time_start"] == "2026-07-01T00:00:00-07:00" + assert captured["data"]["time_end"] == "2026-07-02T00:00:00-07:00" + + +def test_parse_iso_time_requires_timezone(): + """Access-boundary timestamps must carry an explicit timezone offset.""" + import click + + from fulcra_api.cli.utils import parse_iso_time + + dt = parse_iso_time("2026-07-01T00:00:00-07:00", "start time") + assert dt.utcoffset() is not None + + with pytest.raises(click.ClickException, match="timezone offset"): + parse_iso_time("2026-07-01T00:00:00", "start time") + + with pytest.raises(click.ClickException, match="Invalid start time"): + parse_iso_time("not-a-time", "start time") + + +# +# Live integration tests +# + + +def test_group_lifecycle(fulcra_client): + group = fulcra_client.create_group( + title="fulcra-api-python integration test", + responsible_entity="Fulcra Dynamics", + description="Temporary group created by the test suite; safe to delete.", + fulcra_data_types=["StepCount"], + group_url="https://fulcradynamics.com/", + ) + group_id = group["id"] + assert group["is_public"] is False # groups created via the API are private + + try: + fetched = fulcra_client.get_group(group_id) + assert fetched["title"] == "fulcra-api-python integration test" + + updated = fulcra_client.update_group( + group_id, description="Updated by the test suite." + ) + assert updated["description"] == "Updated by the test suite." + assert updated["title"] == "fulcra-api-python integration test" + + updated = fulcra_client.update_group( + group_id, header_image_url="https://fulcradynamics.com/header.png" + ) + assert updated["header_image_url"] == "https://fulcradynamics.com/header.png" + # Fields that are not passed stay untouched; an explicit None clears. + updated = fulcra_client.update_group(group_id, header_image_url=None) + assert updated["header_image_url"] is None + assert updated["description"] == "Updated by the test suite." + + # The owner joins their own group, acting as a participant. + membership = fulcra_client.join_group(group_id) + participant_id = membership["participant_id"] + + joined = fulcra_client.get_groups(subscribed_only=True) + ours = next(g for g in joined if g["id"] == group_id) + assert ours["participant_id"] == participant_id + + participant_ids = fulcra_client.get_group_participants(group_id) + assert participant_id in participant_ids + + # Participant metadata round trip, including clearing it (regression + # test for empty-body requests). + participant = fulcra_client.group_participant(group_id, participant_id) + participant.set_metadata({"nickname": "tester"}) + assert participant.get_metadata() == {"nickname": "tester"} + participant.update_metadata({"score": 42}) + assert participant.get_metadata() == {"nickname": "tester", "score": 42} + participant.set_metadata({}) + assert participant.get_metadata() == {} + + # Data access through the accessor; the participant's data is the + # owner's own since the owner joined the group. + samples = participant.metric_samples( + start_time="2024-01-24 00:00:00-08:00", + end_time="2024-01-25 00:00:00-08:00", + metric="StepCount", + ) + assert isinstance(samples, List) + df = participant.metric_time_series( + start_time="2024-01-24 00:00:00-08:00", + end_time="2024-01-25 00:00:00-08:00", + sample_rate=60, + metric="StepCount", + ) + assert df.shape == (1440, 1) + + # Metrics outside the group's shared data types must be denied. + with pytest.raises(HTTPError): + participant.metric_samples( + start_time="2024-01-24 00:00:00-08:00", + end_time="2024-01-25 00:00:00-08:00", + metric="HeartRate", + ) + + fulcra_client.leave_group(group_id) + joined = fulcra_client.get_groups(subscribed_only=True) + assert not any(g["id"] == group_id for g in joined) + finally: + fulcra_client.delete_group(group_id) + + with pytest.raises(HTTPError): + fulcra_client.get_group(group_id) + + +def test_group_data_access_boundaries(fulcra_client): + """ + A group only grants access to its own data types, within its own time + range, for its own participant IDs; everything else must be denied. + """ + time_start = datetime.datetime.fromisoformat("2024-01-24 00:00:00-08:00") + time_end = datetime.datetime.fromisoformat("2024-01-26 00:00:00-08:00") + group = fulcra_client.create_group( + title="fulcra-api-python boundary test", + responsible_entity="Fulcra Dynamics", + description="Temporary group created by the test suite; safe to delete.", + fulcra_data_types=["StepCount"], + group_url="https://fulcradynamics.com/", + time_start=time_start, + time_end=time_end, + ) + group_id = group["id"] + + try: + membership = fulcra_client.join_group(group_id) + participant_id = membership["participant_id"] + + participant_ids = fulcra_client.get_group_participants(group_id) + assert participant_ids == [participant_id] + + participant = fulcra_client.group_participant(group_id, participant_id) + + # Valid requests: shared metric, range inside the group's time range. + samples = participant.metric_samples( + start_time="2024-01-24 00:00:00-08:00", + end_time="2024-01-25 00:00:00-08:00", + metric="StepCount", + ) + assert isinstance(samples, List) + df = participant.metric_time_series( + start_time="2024-01-24 06:00:00-08:00", + end_time="2024-01-24 18:00:00-08:00", + sample_rate=60, + metric="StepCount", + ) + assert df.shape == (720, 1) + + # Invalid requests must all be denied. + denied_requests = [ + # entirely before the group's time range + {"start_time": "2024-01-22 00:00:00-08:00", + "end_time": "2024-01-23 00:00:00-08:00", + "metric": "StepCount"}, + # entirely after the group's time range + {"start_time": "2024-01-27 00:00:00-08:00", + "end_time": "2024-01-28 00:00:00-08:00", + "metric": "StepCount"}, + # straddling the start of the range + {"start_time": "2024-01-23 00:00:00-08:00", + "end_time": "2024-01-25 00:00:00-08:00", + "metric": "StepCount"}, + # straddling the end of the range + {"start_time": "2024-01-25 00:00:00-08:00", + "end_time": "2024-01-27 00:00:00-08:00", + "metric": "StepCount"}, + # inverted range + {"start_time": "2024-01-25 00:00:00-08:00", + "end_time": "2024-01-24 00:00:00-08:00", + "metric": "StepCount"}, + # metric that is not part of the group + {"start_time": "2024-01-24 00:00:00-08:00", + "end_time": "2024-01-25 00:00:00-08:00", + "metric": "HeartRate"}, + ] + for request in denied_requests: + with pytest.raises(HTTPError): + participant.metric_samples(**request) + with pytest.raises(HTTPError): + participant.metric_time_series(sample_rate=60, **request) + + # A participant ID that does not exist in the group must be denied, + # even for an otherwise-valid request. + bogus = fulcra_client.group_participant( + group_id, "13371337-1337-1337-81e7-a102ab7d3ff8" + ) + with pytest.raises(HTTPError): + bogus.metric_samples( + start_time="2024-01-24 00:00:00-08:00", + end_time="2024-01-25 00:00:00-08:00", + metric="StepCount", + ) + finally: + fulcra_client.delete_group(group_id) + + +def test_group_v1alpha1_data_access(fulcra_client): + """ + v1alpha1 data (annotations) must be accessible for group participants, + scoped to the group's shared data types. + """ + import time + import uuid + + record_id = str(uuid.uuid4()) + fulcra_client.record_data_type( + "MomentAnnotation", + [{"id": record_id, "note": "group-v1-access-test"}], + "v1alpha1", + ) + + now = datetime.datetime.now(datetime.timezone.utc) + start = (now - datetime.timedelta(hours=1)).isoformat() + end = (now + datetime.timedelta(hours=1)).isoformat() + + # Ingestion is asynchronous; wait for the record to become queryable. + for _ in range(15): + direct = fulcra_client.moment_annotations(start, end) + if any(r.get("id") == record_id for r in direct): + break + time.sleep(2) + else: + raise AssertionError("ingested record never became queryable") + + group = fulcra_client.create_group( + title="fulcra-api-python v1alpha1 test", + responsible_entity="Fulcra Dynamics", + description="Temporary group created by the test suite; safe to delete.", + fulcra_data_types=["MomentAnnotation"], + group_url="https://fulcradynamics.com/", + ) + group_id = group["id"] + + try: + participant_id = fulcra_client.join_group(group_id)["participant_id"] + participant = fulcra_client.group_participant(group_id, participant_id) + + pooled = participant.moment_annotations(start, end) + assert any(r.get("id") == record_id for r in pooled) + + # Annotation types outside the group's shared types must be denied. + with pytest.raises(HTTPError): + participant.duration_annotations(start, end) + finally: + fulcra_client.delete_group(group_id) + fulcra_client.record_data_type( + "DeletedRecord", + [{"record_id": record_id, "data_type": "MomentAnnotation"}], + "v1alpha1", + ) + + +def test_get_groups_public(fulcra_client): + groups = fulcra_client.get_groups() + assert isinstance(groups, List) + for group in groups: + assert group["is_public"] is True + + +def test_group_jwks(fulcra_client): + jwks = fulcra_client.get_group_jwks() + assert isinstance(jwks, dict) + assert "keys" in jwks