From 97637730f98b1b6b9df6590fa4fdd429e3b099ea Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Tue, 28 Jul 2026 23:27:06 -0700 Subject: [PATCH 01/15] initial. Lightly edited slop descriptions though --- fulcra_api/cli/__init__.py | 2 + fulcra_api/cli/groups.py | 457 +++++++++++++++++++++++++++++++++++++ fulcra_api/core.py | 288 +++++++++++++++++++++++ 3 files changed, 747 insertions(+) create mode 100644 fulcra_api/cli/groups.py 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/groups.py b/fulcra_api/cli/groups.py new file mode 100644 index 0000000..1b8ffa8 --- /dev/null +++ b/fulcra_api/cli/groups.py @@ -0,0 +1,457 @@ +import json +from datetime import datetime +from urllib.error import HTTPError + +import click + +from fulcra_api.core import FulcraAPI + +from .utils import pass_fulcra_api, requires_auth + + +@click.group(help="Data group management sub-commands") +def group(): + pass + + +def _parse_json_option(value: str, option_name: str) -> dict: + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise click.ClickException(f"Invalid JSON for {option_name}: {exc}") + if not isinstance(parsed, dict): + raise click.ClickException(f"{option_name} must be a JSON object") + return parsed + + +def _parse_time_option(value: str, option_name: str) -> datetime: + try: + return datetime.fromisoformat(value) + except ValueError: + raise click.ClickException( + f"Invalid {option_name} format: {value}. Use ISO8601 format." + ) + + +@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 instead of all public groups", +) +@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 single 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 of the group") +@click.option( + "--public/--private", + "is_public", + default=False, + help="Whether the group is publicly listed (default: private)", +) +@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("--annotations", help="Additional group annotations (JSON object)") +@click.option("--view-description", help="Description of the group's view (JSON object)") +@click.option("--friendly-id", help="Human-friendly identifier for the group") +@pass_fulcra_api +@requires_auth +def create( + fulcra_api: FulcraAPI, + title, + is_public, + responsible_entity, + description, + data_types, + group_url, + start_time, + end_time, + detail_markdown, + agreement_markdown, + withdraw_markdown, + header_image_url, + preview_image_url, + annotations, + view_description, + 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 public group: + fulcra group create --title "Step Challenge" --public \\ + --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} + + # TEMPORARY: Allow "calendars" and "calendar_events" even though they're not + # in the v1 catalog yet. Remove this special case once they're added to the catalog. + temporary_allowed_types = {"calendars", "calendar_events"} + + 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_time_option(start_time, "start time") if start_time else None + ) + parsed_end_time = _parse_time_option(end_time, "end time") if end_time else None + parsed_annotations = ( + _parse_json_option(annotations, "--annotations") if annotations else None + ) + parsed_view_description = ( + _parse_json_option(view_description, "--view-description") + if view_description + else None + ) + + try: + result = fulcra_api.create_group( + title=title, + is_public=is_public, + 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, + annotations=parsed_annotations, + view_description=parsed_view_description, + 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("--preview-image-url", help="New URL of the group's preview image") +@click.option( + "--view-description", help="New description of the group's view (JSON object)" +) +@pass_fulcra_api +@requires_auth +def update( + fulcra_api: FulcraAPI, + group_id: str, + description, + header_image_url, + preview_image_url, + 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. + + GROUP_ID: UUID of the group to update + """ + if not any([description, header_image_url, preview_image_url, view_description]): + raise click.UsageError("Must specify at least one option to update") + + parsed_view_description = ( + _parse_json_option(view_description, "--view-description") + if view_description + else None + ) + + try: + result = fulcra_api.update_group( + group_id=group_id, + description=description, + header_image_url=header_image_url, + preview_image_url=preview_image_url, + view_description=parsed_view_description, + ) + 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_option(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_option(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/core.py b/fulcra_api/core.py index c720e3b..ae577a1 100644 --- a/fulcra_api/core.py +++ b/fulcra_api/core.py @@ -2221,3 +2221,291 @@ 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, + is_public: bool, + 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, + annotations: Optional[dict] = None, + view_description: Optional[dict] = 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 + is_public: Whether the group is publicly listed + 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 + time_end: Optional end of the shared data time range + 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 + annotations: Optional dict of additional group annotations + view_description: Optional dict describing the group's view + 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", + ... is_public=True, + ... responsible_entity="Fulcra Dynamics", + ... description="A month-long step challenge.", + ... fulcra_data_types=["StepCount"], + ... group_url="https://example.com/challenge", + ... ) + """ + group_body = { + "title": title, + "is_public": is_public, + "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, + "annotations": annotations, + "view_description": view_description, + "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] = None, + header_image_url: Optional[str] = None, + preview_image_url: Optional[str] = None, + view_description: Optional[dict] = None, + ) -> 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 left as None are not modified. + + 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 + preview_image_url: New URL of the group's preview image + view_description: New dict describing the group's view + + Returns: + The updated group, represented by a dict. + """ + 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 None + } + 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. + + Returns: + The JWKS, represented by a dict. + """ + resp = self.fulcra_api("/user/v1alpha1/pool/.well-known/jwks.json") + return json.loads(resp) From dcab95f20d9b196293a394a337f7528fed4cca03 Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Tue, 28 Jul 2026 23:54:53 -0700 Subject: [PATCH 02/15] initial crack at adding accessors for group participants --- docs/fulcraapi.md | 5 + fulcra_api/core.py | 2061 ++++++++++++++++++++++++-------------------- mkdocs.yml | 2 + 3 files changed, 1118 insertions(+), 950 deletions(-) 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/core.py b/fulcra_api/core.py index ae577a1..c3628f1 100644 --- a/fulcra_api/core.py +++ b/fulcra_api/core.py @@ -38,983 +38,1211 @@ ) -class FulcraAPI: +class FulcraV0DataMixin: """ - The main class for making Fulcra API functions. + Shared implementations of the v0 data-access operations. - This contains functions for authorizing a token, authenticating HTTP requests, - making calls, and loading data. + 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`. """ - 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 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. - # 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, - ) + Requires an authorized access token. - self.fulcra_credentials = credentials + 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. - 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 + Returns: + A list of dicts, each of which contains the data from a workout. - # 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 + Examples: + To retrieve all workouts during a time period: - self.fulcra_credentials = FulcraCredentials(**kwargs) + >>> workouts = fulcra.apple_workouts( + ... start_time = "2023-09-21 07:00:00.000Z", + ... end_time = "2023-09-22 07:00:00.000Z" + ... ) - self.refresh_callback = refresh_callback + To inspect the details of a workout: - 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. - """ + >>> 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' ... } - 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) + """ + 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) - def authorize(self): + 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]: """ - Request a device token, then prompt the user to authorize it. + Retrieve the raw samples related to the given metric that occurred for the + user during the specified period of time. - This uses the Device Authorization workflow, which requires the user - to visit a link and confirm that the code shown on the screen matches. + 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. - 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). + 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. - The function will wait until the user visits the page and authentiactes, or - until a specified time has passed. + Requires an authorized access token. - Raises an exception on failure. + 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 + 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") - # 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") - - headers["Content-Length"] = str(len(ds)) - else: - ds = None - - 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 - ) - raise - - def fulcra_v1_api( - self, data_class: str, data_type: str, params: dict = {} - ) -> bytes: - """ - Make a call to the v1 API. - - 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 - - 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. - - Supports annotation shorthands with UUIDs (e.g., "metric/MomentAnnotation/"). - - Params: - path: The full path after /data/v1alpha1/ (e.g., "event/MomentAnnotation" or "metric/NumericAnnotation/") - params: Additional params to add to the query - - Returns: - The raw response data (as bytes). Raises an exception on failure. - """ - return self.fulcra_api(f"/data/v1alpha1/{path}", query=params if params else {}) - - def get_token_claims(self) -> dict: - """ - Decode and return all claims from the access token. - - Returns: - 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.") - segs = self.fulcra_credentials.access_token.split(".") - if len(segs) < 2: - raise Exception("Authorized token is in an incorrect format.") - payload = segs[1] + "==" # add extra padding to prevent b64decode from breaking - return json.loads(base64.b64decode(payload)) - - def get_fulcra_userid(self) -> str: - """ - Retrieve the currently authorized Fulcra UserID. - - Returns: - the Fulcra UserID of the currently-authorized user. - """ - claims = self.get_token_claims() - return claims["fulcradynamics.com/userid"] - - def calendars( + 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 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. + Retrieve a time series of locations that the user was at. This uses + the most precise underlying data sources available at the given time. - 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 + 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. Returns: - A list of dicts, each of which represents a calendar. + A list of samples; each sample represents a location sample. Examples: - To retrieve all calendars from your data store: - - >>> calendars = fulcra.calendars() - >>> - - To inspect the details of a calendar: - - >>> 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'} - - + >>> 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... """ - if fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/calendars") + 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 calendar_events( + def location_at_time( self, - start_time: Union[str, datetime.datetime], - end_time: Union[str, datetime.datetime], - calendar_ids: Optional[List[str]] = None, + time: Union[str, datetime.datetime], + window_size: int = 14400, + include_after: bool = False, + reverse_geocode: bool = False, 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. - - Requires an authorized access token. + 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: - 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. + 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: - A list of dicts, each of which contains the data from a calendar event. + A list of dicts; the first dict is the best location sample. 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"] - ... ) + >>> location = fulcra.location_at_time( + ... time = "2024-01-24 00:00:00-08:00", + ... ) - 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'}]} + >>> 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 = { - "start_time": start_time, - "end_time": end_time, + "time": time, + "window_size": window_size, + "include_after": include_after, + "reverse_geocode": reverse_geocode, } - 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 + self._v0_data_path("location_at_time", fulcra_userid), query=params ) return json.loads(resp) - def apple_workouts( + 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, - ) -> List[Dict]: + ) -> pd.DataFrame: """ - Retrieve the list of Apple workouts that occurred (at least partially) during - the specified time range. + Return sleep cycles summarized from sleep stages. - Requires an authorized access token. + Processes raw sleep data samples into sleep cycles by finding gaps in the + sleep sample data within a specified time interval. + + 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 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 list of dicts, each of which contains the data from a workout. - - Examples: - To retrieve all workouts during a time period: - - >>> workouts = fulcra.apple_workouts( - ... start_time = "2023-09-21 07:00:00.000Z", - ... end_time = "2023-09-22 07:00:00.000Z" - ... ) - - To inspect the details of a workout: - - >>> 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' ... } - + A pandas DataFrame containing the sleep cycle data. """ - 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) - return json.loads(resp) + 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 metric_samples( + resp = self.fulcra_api( + self._v0_data_path("sleep_cycles", fulcra_userid), query=params + ) + return pd.read_feather(io.BytesIO(resp)) + + def sleep_stages( self, start_time: Union[str, datetime.datetime], end_time: Union[str, datetime.datetime], - metric: str, + 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, - ) -> List[Dict]: + ) -> pd.DataFrame: """ - Retrieve the raw samples related to the given metric that occurred for the - user during the specified period of time. + Return sleep stages derived from raw fulcra metric samples. - 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. + 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. - 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. + 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 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. - metric: The name of the metric to retrieve samples for. - fulcra_userid: When present, specifies the Fulcra user ID to request data for. - - Examples: - - >>> samples = fulcra.metric_samples( - ... start_time="2023-08-09 07:00:00.000Z", - ... end_time="2023-08-10 07:00:00.000Z", - ... metric="StepCount" - ... ) - - To inspect the first sample: + 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. - >>> 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'}} + Returns: + A pandas DataFrame containing the sleep stage data. """ - 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) - return json.loads(resp) + 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 gmaps_location_updates( + 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], - fulcra_source_id: Optional[str] = None, + 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, - ) -> List[Dict]: + ) -> pd.DataFrame: """ - Return Google Maps geo-location update samples for a user. + Return sleep cycles aggregated by a specified period. - Retrieve the raw Google Maps location update samples for the specified - user during the specified period of time. + Processes raw sleep data samples into aggregated sleep stage durations per period. - 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. + 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: - A list of dicts, each of which contains the data from a Google Maps location update. + A pandas DataFrame containing the aggregated sleep data. """ - params = {"start_time": start_time, "end_time": end_time} - if fulcra_source_id is not None: - params["fulcra_source_id"] = fulcra_source_id + 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() resp = self.fulcra_api( - f"/data/v0/{fulcra_userid}/gmaps_location_updates", query=params + self._v0_data_path("sleep_agg", fulcra_userid), query=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. + return pd.read_feather(io.BytesIO(resp)) - 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. +class FulcraAPI(FulcraV0DataMixin): + """ + The main class for making Fulcra API functions. - Returns: - A list of dicts, each of which contains the data from a location update. + This contains functions for authorizing a token, authenticating HTTP requests, + making calls, and loading data. + """ - Examples: - To retrieve all location updates within a specific hour: + fulcra_credentials: Optional[FulcraCredentials] = None - >>> updates = fulcra.apple_location_updates( - ... start_time="2023-09-24T20:00:00Z", - ... end_time="2023-09-24T21:10:00Z" - ... ) + 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. - To see the details of the first update: + 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. + """ - >>> 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'} + # 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, + ) + + self.fulcra_credentials = credentials + 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 + + self.fulcra_credentials = FulcraCredentials(**kwargs) + + 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. + + 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). + + 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: + + >>> 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 + + When the authorization succeeds, the following will be displayed: + + ``` + 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} + """ + ) + + 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 get_authorization_code_url( + self, redirect_uri: str, state: Optional[str] = None + ) -> str: + """ + Generates the URL to redirect the user to for the Authorization Code Grant flow. + + 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. - fulcra_userid: When present, specifies the Fulcra user ID to request data for. + 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 list of dicts, each of which contains the data from a location visit. + The authorization URL. + """ + return self.oidc.make_authorization_code_url(redirect_uri, state) - Examples: - To retrieve all location updates within a specific hour: + def set_cached_access_token(self, token: str): + """Deprecated. Directly set access token on credentials.""" + self.fulcra_credentials.access_token = token - >>> visits = fulcra.apple_location_visits( - ... start_time="2023-09-24T20:00:00Z", - ... end_time="2023-09-24T21:10:00Z" - ... ) + 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 - To see the details of the first update: + def set_cached_refresh_token(self, token: str): + """Deprecated. Directly set refresh token on credentials.""" + self.fulcra_credentials.refresh_token = token - >>> 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'} + 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 + 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 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): """ - 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) + Exchanges an authorization code for an access token, refresh token, + and ID token. + + This method should be called after the user has been redirected back + to your application's `redirect_uri` with an authorization `code`. + + Params: + code: The authorization code received from Auth0. + redirect_uri: The same `redirect_uri` that was used when requesting + the authorization code. + + 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 + + def refresh_access_token(self) -> bool: + """ + Refreshes the access token using the stored refresh token. + + Returns: + True if the token was successfully refreshed, False otherwise. + + 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.") + + try: + new_creds = self.oidc.refresh_credentials(self.fulcra_credentials) + except Exception: + return False + + # 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 + + self.fulcra_credentials = new_creds + + if self.refresh_callback is not None: + self.refresh_callback(self.fulcra_credentials) + + return True + + def fulcra_api( + 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: + """ + 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") + + Returns: + The raw response data (as bytes). Raises an exception on failure. + """ + + # 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() + + if self.fulcra_api_is_http: + proto = "http" + else: + proto = "https" + + host = self.fulcra_api_domain + + if self.fulcra_api_port: + host = f"{host}:{self.fulcra_api_port}" + + if query: + url_query = urllib.parse.urlencode(query, doseq=True) + else: + url_query = "" + + url = urllib.parse.urlunparse((proto, host, url_path, "", url_query, "")) + headers = {"Authorization": f"Bearer {self.fulcra_credentials.access_token}"} + + 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") + + headers["Content-Length"] = str(len(ds)) + else: + ds = None + + 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 + ) + raise + + def fulcra_v1_api( + self, data_class: str, data_type: str, params: dict = {} + ) -> bytes: + """ + Make a call to the v1 API. + + 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 - 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: + Returns: + The raw response data (as bytes). Raises an exception on failure. """ - Retrieve time-series data from a single Fulcra metric, covering the - time starting at `start_time` (inclusive) until `end_time` - (exclusive). + # query_params = urllib.parse.urlencode(params, doseq=True) + return self.fulcra_api(f"/data/v1alpha1/{data_class}/{data_type}", query=params) - 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). + 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. - Requires a valid access token. + Supports annotation shorthands with UUIDs (e.g., "metric/MomentAnnotation/"). 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 + path: The full path after /data/v1alpha1/ (e.g., "event/MomentAnnotation" or "metric/NumericAnnotation/") + params: Additional params to add to the query 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: + The raw response data (as bytes). Raises an exception on failure. + """ + return self.fulcra_api(f"/data/v1alpha1/{path}", query=params if params else {}) - >>> 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" - ... ) + 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. - The index of the DataFrame will be the time: + 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}" - >>> 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 get_token_claims(self) -> dict: + """ + Decode and return all claims from the access token. - The non-index column(s) in the dataframe will be related to the metric. + Returns: + 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.") + segs = self.fulcra_credentials.access_token.split(".") + if len(segs) < 2: + raise Exception("Authorized token is in an incorrect format.") + payload = segs[1] + "==" # add extra padding to prevent b64decode from breaking + return json.loads(base64.b64decode(payload)) - >>> df.columns - Index(['step_count'], dtype='object') + def get_fulcra_userid(self) -> str: """ - 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 + Retrieve the currently authorized Fulcra UserID. - 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") + Returns: + the Fulcra UserID of the currently-authorized user. + """ + claims = self.get_token_claims() + return claims["fulcradynamics.com/userid"] - def location_time_series( + def calendars( 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. + Retrieve the list of calendars available in your data store. - Requires a valid access token. + To request the calendars from another user's store, pass their user + ID as the `fulcra_userid` parameter. + + 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 - 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. Returns: - A list of samples; each sample represents a location sample. + A list of dicts, each of which represents a calendar. 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... + To retrieve all calendars from your data store: + + >>> calendars = fulcra.calendars() + >>> + + To inspect the details of a calendar: + + >>> 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'} + + """ - 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 - ) + resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/calendars") return json.loads(resp) - def location_at_time( + def calendar_events( self, - time: Union[str, datetime.datetime], - window_size: int = 14400, - include_after: bool = False, - reverse_geocode: bool = False, + start_time: Union[str, datetime.datetime], + end_time: Union[str, datetime.datetime], + calendar_ids: Optional[List[str]] = None, fulcra_userid: Optional[str] = None, ) -> List[Dict]: """ - 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. + 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. + + Requires an authorized 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. + 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; the first dict is the best location sample. + 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: - >>> location = fulcra.location_at_time( - ... time = "2024-01-24 00:00:00-08:00", - ... ) + >>> 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"] + ... ) - >>> 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'}] + 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 = { - "time": time, - "window_size": window_size, - "include_after": include_after, - "reverse_geocode": reverse_geocode, + "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}/location_at_time", query=params + f"/data/v0/{fulcra_userid}/calendar_events", query=params ) return json.loads(resp) @@ -1432,203 +1660,6 @@ def update_user_preferences(self, prefs: Dict): resp = self.fulcra_api("user/v1alpha1/preferences", method="POST", data=prefs) return json.loads(resp) - 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: - """ - Return sleep cycles summarized from sleep stages. - - Processes raw sleep data samples into sleep cycles by finding gaps in the - sleep sample data within a specified time interval. - - 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. - 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 pandas DataFrame containing the sleep cycle data. - """ - 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 fulcra_userid is None: - fulcra_userid = self.get_fulcra_userid() - - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/sleep_cycles", query=params) - return pd.read_feather(io.BytesIO(resp)) - - 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: - """ - Return sleep stages derived from raw fulcra metric samples. - - 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. - - 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: - A pandas DataFrame containing the sleep stage data. - """ - 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() - - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/sleep_stages", 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: - """ - Return sleep cycles aggregated by a specified period. - - Processes raw sleep data samples into aggregated sleep stage durations per period. - - 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. - 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: - A pandas DataFrame containing the aggregated sleep data. - """ - 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() - - resp = self.fulcra_api(f"/data/v0/{fulcra_userid}/sleep_agg", query=params) - return pd.read_feather(io.BytesIO(resp)) - def annotations_catalog( self, fulcra_userid: Optional[str] = None, @@ -2509,3 +2540,133 @@ def get_group_jwks(self) -> 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(FulcraV0DataMixin): + """ + 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`, `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 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/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: From b4a7efd44ffb93eaeff8d8938c74d196bf0a587d Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Wed, 29 Jul 2026 13:38:30 -0700 Subject: [PATCH 03/15] update this --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 ``` From b9fbda377480f47a9bf3086e1e2accabc19fa846 Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Thu, 30 Jul 2026 12:33:36 -0700 Subject: [PATCH 04/15] - add a test for groups - update CLI group commands to warn about specific properties --- fulcra_api/cli/groups.py | 70 +++++++-- tests/test_groups.py | 297 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 15 deletions(-) create mode 100644 tests/test_groups.py diff --git a/fulcra_api/cli/groups.py b/fulcra_api/cli/groups.py index 1b8ffa8..9292517 100644 --- a/fulcra_api/cli/groups.py +++ b/fulcra_api/cli/groups.py @@ -213,10 +213,28 @@ def create( @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( @@ -224,34 +242,56 @@ def update( 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. + parameters are immutable. Fields not specified are left unchanged. GROUP_ID: UUID of the group to update """ - if not any([description, header_image_url, preview_image_url, view_description]): - raise click.UsageError("Must specify at least one option 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" + ) - parsed_view_description = ( - _parse_json_option(view_description, "--view-description") - if view_description - else None - ) + 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_option( + 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, - description=description, - header_image_url=header_image_url, - preview_image_url=preview_image_url, - view_description=parsed_view_description, - ) + 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") diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 0000000..dfe861a --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,297 @@ +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", + "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", "create_group", "authorize"]: + assert not hasattr(participant, name) + + +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 + + +# +# Live integration tests +# + + +def test_group_lifecycle(fulcra_client): + group = fulcra_client.create_group( + title="fulcra-api-python integration test", + is_public=False, + 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 + + 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. + try: + 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", + ) + assert False + except Exception: + assert True + + 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) + + try: + fulcra_client.get_group(group_id) + assert False + except Exception: + assert True + + +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", + is_public=False, + 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_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 From 803d8168c088da9c09c6404ac14d7ff28d526c38 Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Thu, 30 Jul 2026 12:42:37 -0700 Subject: [PATCH 05/15] examples --- fulcra_api/core.py | 73 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/fulcra_api/core.py b/fulcra_api/core.py index c3628f1..e464e75 100644 --- a/fulcra_api/core.py +++ b/fulcra_api/core.py @@ -37,6 +37,10 @@ "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 FulcraV0DataMixin: """ @@ -969,6 +973,7 @@ def fulcra_api( data: dict | List[dict] | None = None, return_http_response: bool = False, content_type: str = "application/json", + authenticated: bool = True, ) -> bytes | http.client.HTTPResponse: """ Make a call to the given url path (e.g. `/v0/data/metric_time_series?...`) @@ -981,13 +986,19 @@ def fulcra_api( 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 raw response data (as bytes). Raises an exception on failure. """ # Attempt to refresh our access token if it's expired - if self.fulcra_credentials is not None and self.fulcra_credentials.is_expired(): + if ( + authenticated + and self.fulcra_credentials is not None + and self.fulcra_credentials.is_expired() + ): self.refresh_access_token() if self.fulcra_api_is_http: @@ -1006,9 +1017,14 @@ def fulcra_api( url_query = "" url = urllib.parse.urlunparse((proto, host, url_path, "", url_query, "")) - headers = {"Authorization": f"Bearer {self.fulcra_credentials.access_token}"} + if authenticated: + headers = { + "Authorization": f"Bearer {self.fulcra_credentials.access_token}" + } + else: + headers = {} - if data: + if data is not None: headers["Content-Type"] = content_type # Serialize data based on content type @@ -1050,7 +1066,10 @@ def fulcra_api( 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 + path, + method="GET", + return_http_response=return_http_response, + authenticated=authenticated, ) raise @@ -2379,26 +2398,50 @@ def create_group( def update_group( self, group_id: str, - description: Optional[str] = None, - header_image_url: Optional[str] = None, - preview_image_url: Optional[str] = None, - view_description: Optional[dict] = None, + 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 left as None are not modified. + 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 - preview_image_url: New URL of the group's preview image - view_description: New dict describing the group's view + 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 @@ -2408,7 +2451,7 @@ def update_group( "preview_image_url": preview_image_url, "view_description": view_description, }.items() - if v is not None + if v is not UNSET } resp = self.fulcra_api( f"/user/v1alpha1/pool/{group_id}", data=group_body, method="PUT" @@ -2535,6 +2578,10 @@ def get_group_jwks(self) -> dict: 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. """ From ae7b5641c2c99ff9c598c34cf137fea6aa383f91 Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Fri, 31 Jul 2026 16:15:44 -0700 Subject: [PATCH 06/15] Add an allow-list for fields in there for future compat --- fulcra_api/credentials.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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} + ) From 19c11135f81bc5a4762e055af60e232d0b4d791f Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Fri, 31 Jul 2026 17:12:08 -0700 Subject: [PATCH 07/15] - Rework the internals of the group vs. non-group data access API. Still using an alternate accessor, but we explicitly use a call to a new function `resolve_data_source` to look at the `group_id` and `participant_id` parameters. If those are present, it uses a group accessor; otherwise it uses the non-group one. - Add CLI errors to make it clear that you can't use `--user-id` and `--group-id` / `--participant-id` at the same time - Simplify query paths a bit in get_records - Bake in `apple_workouts`, `calendars`, and `calendar_events` for now but eventually this goes in the catalog (it's on my list) --- fulcra_api/cli/commands.py | 109 ++++++--- fulcra_api/cli/groups.py | 4 +- fulcra_api/cli/utils.py | 35 +++ fulcra_api/core.py | 457 ++++++++++++++++++++----------------- tests/test_groups.py | 100 +++++++- 5 files changed, 469 insertions(+), 236 deletions(-) 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 index 9292517..18e3322 100644 --- a/fulcra_api/cli/groups.py +++ b/fulcra_api/cli/groups.py @@ -155,7 +155,9 @@ def create( # TEMPORARY: Allow "calendars" and "calendar_events" even though they're not # in the v1 catalog yet. Remove this special case once they're added to the catalog. - temporary_allowed_types = {"calendars", "calendar_events"} + # "apple_workouts" is the resource name the group data routes check for + # workout access, but it is not a catalog ID. + temporary_allowed_types = {"calendars", "calendar_events", "apple_workouts"} invalid_types = [ dt diff --git a/fulcra_api/cli/utils.py b/fulcra_api/cli/utils.py index fb1b722..309eaa4 100644 --- a/fulcra_api/cli/utils.py +++ b/fulcra_api/cli/utils.py @@ -49,6 +49,41 @@ def wrapper(fulcra_api, *args, **kwargs): return wrapper +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 e464e75..d9e4c1e 100644 --- a/fulcra_api/core.py +++ b/fulcra_api/core.py @@ -42,15 +42,17 @@ UNSET: Any = object() -class FulcraV0DataMixin: +class FulcraDataAccessMixin: """ - Shared implementations of the v0 data-access operations. + 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`. + requests are made against by implementing `_v0_data_path` and + `fulcra_v1_api`. """ def _v0_data_path( @@ -75,6 +77,14 @@ def fulcra_api( """ raise NotImplementedError + def fulcra_v1_api( + self, data_class: str, data_type: str, params: dict = {} + ) -> bytes: + """ + Make a call to the v1 API. + """ + raise NotImplementedError + def apple_workouts( self, start_time: Union[str, datetime.datetime], @@ -681,7 +691,203 @@ def sleep_agg( return pd.read_feather(io.BytesIO(resp)) -class FulcraAPI(FulcraV0DataMixin): + 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]: + """ + Retrieves recorded Moment Annotations, along with any metadata, for the requested time ranges. + + 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 + 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. + + """ + 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", "MomentAnnotation", params) + 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]: + """ + Retrieves recorded Duration Annotations, along with any metadata, for the requested time ranges. + + 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 + 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. + + """ + 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 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]: + """ + Retrieves recorded Boolean Annotations, along with any metadata, for the requested time ranges. + + 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 + 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. + + """ + 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", "BooleanAnnotation", params) + 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]: + """ + Retrieves recorded Numeric Annotations, along with any metadata, for the requested time ranges. + + 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 + 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. + + """ + 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", "NumericAnnotation", params) + return json.loads(resp) + + def scale_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]: + """ + Retrieves recorded Scale Annotations, along with any metadata, for the requested time ranges. + + 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 + 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. + + """ + 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) + + +class FulcraAPI(FulcraDataAccessMixin): """ The main class for making Fulcra API functions. @@ -1706,202 +1912,7 @@ def annotations_catalog( if fulcra_userid is not None: params["fulcra_userid"] = fulcra_userid - resp = self.fulcra_api("/user/v1alpha1/annotation") - return json.loads(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]: - """ - Retrieves recorded Moment Annotations, along with any metadata, for the requested time ranges. - - 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 - 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. - - """ - 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", "MomentAnnotation", params) - 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]: - """ - Retrieves recorded Duration Annotations, along with any metadata, for the requested time ranges. - - 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 - 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. - - """ - 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 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]: - """ - Retrieves recorded Boolean Annotations, along with any metadata, for the requested time ranges. - - 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 - 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. - - """ - 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", "BooleanAnnotation", params) - 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]: - """ - Retrieves recorded Numeric Annotations, along with any metadata, for the requested time ranges. - - 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 - 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. - - """ - 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", "NumericAnnotation", params) - return json.loads(resp) - - def scale_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]: - """ - Retrieves recorded Scale Annotations, along with any metadata, for the requested time ranges. - - 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 - 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. - - """ - 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]]: @@ -2619,17 +2630,18 @@ def group_participant( return FulcraGroupParticipant(self, group_id, participant_id) -class FulcraGroupParticipant(FulcraV0DataMixin): +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`, `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. + 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. @@ -2679,6 +2691,39 @@ def _v0_data_path( 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. diff --git a/tests/test_groups.py b/tests/test_groups.py index dfe861a..e1b7221 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -60,16 +60,57 @@ def test_group_participant_method_surface(): "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", "create_group", "authorize"]: + 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() @@ -284,6 +325,63 @@ def test_group_data_access_boundaries(fulcra_client): 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", + is_public=False, + 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) From bc20032f54c07bf473a64f1e4bf91844ed1f7f6c Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Fri, 31 Jul 2026 17:20:59 -0700 Subject: [PATCH 08/15] factor out time format checks --- fulcra_api/cli/groups.py | 36 ++++++++---------------------------- fulcra_api/cli/share.py | 37 +++++++------------------------------ fulcra_api/cli/utils.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 58 deletions(-) diff --git a/fulcra_api/cli/groups.py b/fulcra_api/cli/groups.py index 18e3322..e8884d8 100644 --- a/fulcra_api/cli/groups.py +++ b/fulcra_api/cli/groups.py @@ -1,12 +1,11 @@ import json -from datetime import datetime from urllib.error import HTTPError import click from fulcra_api.core import FulcraAPI -from .utils import pass_fulcra_api, requires_auth +from .utils import parse_iso_time, parse_json_object, pass_fulcra_api, requires_auth @click.group(help="Data group management sub-commands") @@ -14,25 +13,6 @@ def group(): pass -def _parse_json_option(value: str, option_name: str) -> dict: - try: - parsed = json.loads(value) - except json.JSONDecodeError as exc: - raise click.ClickException(f"Invalid JSON for {option_name}: {exc}") - if not isinstance(parsed, dict): - raise click.ClickException(f"{option_name} must be a JSON object") - return parsed - - -def _parse_time_option(value: str, option_name: str) -> datetime: - try: - return datetime.fromisoformat(value) - except ValueError: - raise click.ClickException( - f"Invalid {option_name} format: {value}. Use ISO8601 format." - ) - - @group.command("list", short_help="List public groups, or groups you've joined") @click.option( "--joined", @@ -174,14 +154,14 @@ def create( raise click.ClickException(f"Failed to fetch catalog: {exc}\n{error_body}") parsed_start_time = ( - _parse_time_option(start_time, "start time") if start_time else None + parse_iso_time(start_time, "start time") if start_time else None ) - parsed_end_time = _parse_time_option(end_time, "end time") if end_time else None + parsed_end_time = parse_iso_time(end_time, "end time") if end_time else None parsed_annotations = ( - _parse_json_option(annotations, "--annotations") if annotations else None + parse_json_object(annotations, "--annotations") if annotations else None ) parsed_view_description = ( - _parse_json_option(view_description, "--view-description") + parse_json_object(view_description, "--view-description") if view_description else None ) @@ -283,7 +263,7 @@ def update( elif no_preview_image_url: kwargs["preview_image_url"] = None if view_description: - kwargs["view_description"] = _parse_json_option( + kwargs["view_description"] = parse_json_object( view_description, "--view-description" ) elif no_view_description: @@ -429,7 +409,7 @@ def set_metadata( \b fulcra group set-metadata '{"nickname": "speedy"}' """ - parsed_metadata = _parse_json_option(metadata, "METADATA") + parsed_metadata = parse_json_object(metadata, "METADATA") try: fulcra_api.set_group_participant_metadata( @@ -468,7 +448,7 @@ def update_metadata( \b fulcra group update-metadata '{"score": 42}' """ - parsed_values = _parse_json_option(values, "VALUES") + parsed_values = parse_json_object(values, "VALUES") try: fulcra_api.update_group_participant_metadata( diff --git a/fulcra_api/cli/share.py b/fulcra_api/cli/share.py index 3aafbf6..5b78dad 100644 --- a/fulcra_api/cli/share.py +++ b/fulcra_api/cli/share.py @@ -7,7 +7,7 @@ from fulcra_api.core import FulcraAPI -from .utils import pass_fulcra_api, requires_auth +from .utils import parse_iso_time, pass_fulcra_api, requires_auth @click.group(help="Data sharing management sub-commands") @@ -124,23 +124,10 @@ def create( raise click.ClickException(f"Failed to fetch catalog: {exc}\n{error_body}") # 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 # Create the datashare try: @@ -453,23 +440,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 309eaa4..974b24f 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,39 @@ 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. + + Params: + value: The raw string to parse + name: What the value is, for the error message (e.g. "start time") + """ + try: + return datetime.fromisoformat(value) + except ValueError: + raise click.ClickException( + f"Invalid {name} format: {value}. Use ISO8601 format." + ) + + +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, From 3cc44d6e1293963a353ed8f8a56c3ed8363e5839 Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Fri, 31 Jul 2026 17:26:28 -0700 Subject: [PATCH 09/15] Take out annotations and view_description from group creation right now. --- fulcra_api/cli/groups.py | 14 -------------- fulcra_api/core.py | 6 ------ 2 files changed, 20 deletions(-) diff --git a/fulcra_api/cli/groups.py b/fulcra_api/cli/groups.py index e8884d8..9a8d8dc 100644 --- a/fulcra_api/cli/groups.py +++ b/fulcra_api/cli/groups.py @@ -87,8 +87,6 @@ def show(fulcra_api: FulcraAPI, group_id: str): @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("--annotations", help="Additional group annotations (JSON object)") -@click.option("--view-description", help="Description of the group's view (JSON object)") @click.option("--friendly-id", help="Human-friendly identifier for the group") @pass_fulcra_api @requires_auth @@ -107,8 +105,6 @@ def create( withdraw_markdown, header_image_url, preview_image_url, - annotations, - view_description, friendly_id, ): """ @@ -157,14 +153,6 @@ def create( 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 - parsed_annotations = ( - parse_json_object(annotations, "--annotations") if annotations else None - ) - parsed_view_description = ( - parse_json_object(view_description, "--view-description") - if view_description - else None - ) try: result = fulcra_api.create_group( @@ -181,8 +169,6 @@ def create( withdraw_markdown=withdraw_markdown, header_image_url=header_image_url, preview_image_url=preview_image_url, - annotations=parsed_annotations, - view_description=parsed_view_description, friendly_id=friendly_id, ) click.echo(json.dumps(result)) diff --git a/fulcra_api/core.py b/fulcra_api/core.py index d9e4c1e..701f81f 100644 --- a/fulcra_api/core.py +++ b/fulcra_api/core.py @@ -2337,8 +2337,6 @@ def create_group( withdraw_markdown: Optional[str] = None, header_image_url: Optional[str] = None, preview_image_url: Optional[str] = None, - annotations: Optional[dict] = None, - view_description: Optional[dict] = None, friendly_id: Optional[str] = None, ) -> dict: """ @@ -2368,8 +2366,6 @@ def create_group( 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 - annotations: Optional dict of additional group annotations - view_description: Optional dict describing the group's view friendly_id: Optional human-friendly identifier for the group Returns: @@ -2399,8 +2395,6 @@ def create_group( "withdraw_markdown": withdraw_markdown, "header_image_url": header_image_url, "preview_image_url": preview_image_url, - "annotations": annotations, - "view_description": view_description, "friendly_id": friendly_id, } resp = self.fulcra_api("/user/v1alpha1/pool", data=group_body, method="POST") From ed6dd2d63d3d6e195854ce0dcc69c44f89b9e008 Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Fri, 31 Jul 2026 17:56:19 -0700 Subject: [PATCH 10/15] take calendars out for now until we work out how they should be accessed, probably through v1 --- fulcra_api/cli/groups.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/fulcra_api/cli/groups.py b/fulcra_api/cli/groups.py index 9a8d8dc..4083215 100644 --- a/fulcra_api/cli/groups.py +++ b/fulcra_api/cli/groups.py @@ -129,11 +129,9 @@ def create( catalog = fulcra_api.v1_catalog() valid_data_type_ids = {item["id"] for item in catalog} - # TEMPORARY: Allow "calendars" and "calendar_events" even though they're not - # in the v1 catalog yet. Remove this special case once they're added to the 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 = {"calendars", "calendar_events", "apple_workouts"} + temporary_allowed_types = {"apple_workouts"} invalid_types = [ dt From 1d5a13d138df396adf2829d9d895891308a07abf Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Fri, 31 Jul 2026 18:04:47 -0700 Subject: [PATCH 11/15] no, really, use a time zone --- fulcra_api/cli/utils.py | 11 ++++++++++- tests/test_groups.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/fulcra_api/cli/utils.py b/fulcra_api/cli/utils.py index 974b24f..b45aa3b 100644 --- a/fulcra_api/cli/utils.py +++ b/fulcra_api/cli/utils.py @@ -54,16 +54,25 @@ 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: - return datetime.fromisoformat(value) + 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: diff --git a/tests/test_groups.py b/tests/test_groups.py index e1b7221..d25c986 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -137,6 +137,22 @@ def read(self): assert captured["content_type"] is None +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 # From 2809197e01703c1b6d7d1af6387f4d08001a42d7 Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Sat, 1 Aug 2026 10:46:12 -0700 Subject: [PATCH 12/15] simpler descriptions --- fulcra_api/cli/groups.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fulcra_api/cli/groups.py b/fulcra_api/cli/groups.py index 4083215..69d8cfe 100644 --- a/fulcra_api/cli/groups.py +++ b/fulcra_api/cli/groups.py @@ -18,7 +18,7 @@ def group(): "--joined", is_flag=True, default=False, - help="List only groups you have joined instead of all public groups", + help="List only groups you have joined, including participant ID", ) @pass_fulcra_api @requires_auth @@ -45,7 +45,7 @@ def list_groups(fulcra_api: FulcraAPI, joined: bool): @requires_auth def show(fulcra_api: FulcraAPI, group_id: str): """ - Show the description of a single data group. + Show the description of a data group. GROUP_ID: UUID of the group """ @@ -59,7 +59,7 @@ def show(fulcra_api: FulcraAPI, group_id: str): @group.command("create", short_help="Create a new group") -@click.option("--title", required=True, help="Title of the group") +@click.option("--title", required=True, help="Title") @click.option( "--public/--private", "is_public", From d010a5c68236df42a1fa4181f5efc7826e3a40b8 Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Wed, 12 Aug 2026 14:10:27 -0700 Subject: [PATCH 13/15] Don't expose public groups here yet --- fulcra_api/cli/groups.py | 12 ++---------- fulcra_api/core.py | 5 +---- tests/test_groups.py | 37 +++++++++++++++++++++++++++++++++---- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/fulcra_api/cli/groups.py b/fulcra_api/cli/groups.py index 69d8cfe..5c3bba1 100644 --- a/fulcra_api/cli/groups.py +++ b/fulcra_api/cli/groups.py @@ -60,12 +60,6 @@ def show(fulcra_api: FulcraAPI, group_id: str): @group.command("create", short_help="Create a new group") @click.option("--title", required=True, help="Title") -@click.option( - "--public/--private", - "is_public", - default=False, - help="Whether the group is publicly listed (default: private)", -) @click.option( "--responsible-entity", required=True, @@ -93,7 +87,6 @@ def show(fulcra_api: FulcraAPI, group_id: str): def create( fulcra_api: FulcraAPI, title, - is_public, responsible_entity, description, data_types, @@ -118,8 +111,8 @@ def create( Examples: \b - Create a public group: - fulcra group create --title "Step Challenge" --public \\ + 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 @@ -155,7 +148,6 @@ def create( try: result = fulcra_api.create_group( title=title, - is_public=is_public, responsible_entity=responsible_entity, description=description, fulcra_data_types=sorted(data_types), diff --git a/fulcra_api/core.py b/fulcra_api/core.py index c4239a9..4fbd4a6 100644 --- a/fulcra_api/core.py +++ b/fulcra_api/core.py @@ -2418,7 +2418,6 @@ def get_group(self, group_id: str) -> dict: def create_group( self, title: str, - is_public: bool, responsible_entity: str, description: str, fulcra_data_types: List[str], @@ -2445,7 +2444,6 @@ def create_group( Args: title: Title of the group - is_public: Whether the group is publicly listed responsible_entity: The person or organization responsible for the group description: Description of the group @@ -2467,7 +2465,6 @@ def create_group( Examples: >>> group = fulcra_client.create_group( ... title="Step Challenge", - ... is_public=True, ... responsible_entity="Fulcra Dynamics", ... description="A month-long step challenge.", ... fulcra_data_types=["StepCount"], @@ -2476,7 +2473,7 @@ def create_group( """ group_body = { "title": title, - "is_public": is_public, + "is_public": False, "responsible_entity": responsible_entity, "description": description, "time_start": time_start.isoformat() if time_start else None, diff --git a/tests/test_groups.py b/tests/test_groups.py index d25c986..5c6c4b4 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -137,6 +137,38 @@ def read(self): 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 + + def test_parse_iso_time_requires_timezone(): """Access-boundary timestamps must carry an explicit timezone offset.""" import click @@ -161,14 +193,13 @@ def test_parse_iso_time_requires_timezone(): def test_group_lifecycle(fulcra_client): group = fulcra_client.create_group( title="fulcra-api-python integration test", - is_public=False, 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 + assert group["is_public"] is False # groups created via the API are private try: fetched = fulcra_client.get_group(group_id) @@ -259,7 +290,6 @@ def test_group_data_access_boundaries(fulcra_client): time_end = datetime.datetime.fromisoformat("2024-01-26 00:00:00-08:00") group = fulcra_client.create_group( title="fulcra-api-python boundary test", - is_public=False, responsible_entity="Fulcra Dynamics", description="Temporary group created by the test suite; safe to delete.", fulcra_data_types=["StepCount"], @@ -371,7 +401,6 @@ def test_group_v1alpha1_data_access(fulcra_client): group = fulcra_client.create_group( title="fulcra-api-python v1alpha1 test", - is_public=False, responsible_entity="Fulcra Dynamics", description="Temporary group created by the test suite; safe to delete.", fulcra_data_types=["MomentAnnotation"], From f6530c51d834d9afd8778269d9bd4483364fe005 Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Wed, 12 Aug 2026 14:58:05 -0700 Subject: [PATCH 14/15] more tz enforcement --- fulcra_api/core.py | 15 ++++++++++-- tests/test_groups.py | 58 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/fulcra_api/core.py b/fulcra_api/core.py index 4fbd4a6..e2a620e 100644 --- a/fulcra_api/core.py +++ b/fulcra_api/core.py @@ -2450,8 +2450,10 @@ def create_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 - time_end: Optional end of the shared data time range + 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 @@ -2471,6 +2473,15 @@ def create_group( ... 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, diff --git a/tests/test_groups.py b/tests/test_groups.py index 5c6c4b4..1e1fa83 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -169,6 +169,54 @@ def fake_fulcra_api(url_path, method="GET", data=None, **kwargs): 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 @@ -258,15 +306,12 @@ def test_group_lifecycle(fulcra_client): assert df.shape == (1440, 1) # Metrics outside the group's shared data types must be denied. - try: + 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", ) - assert False - except Exception: - assert True fulcra_client.leave_group(group_id) joined = fulcra_client.get_groups(subscribed_only=True) @@ -274,11 +319,8 @@ def test_group_lifecycle(fulcra_client): finally: fulcra_client.delete_group(group_id) - try: + with pytest.raises(HTTPError): fulcra_client.get_group(group_id) - assert False - except Exception: - assert True def test_group_data_access_boundaries(fulcra_client): From 08edbe94c985b93a57d1b8bc3ba4d2749348e85d Mon Sep 17 00:00:00 2001 From: "brandon creighton, aka cstone" Date: Wed, 12 Aug 2026 14:58:10 -0700 Subject: [PATCH 15/15] update this --- AGENTS.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) 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.