Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/agents/api-feature-developer.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: api-feature-developer
description: SDK API feature development specialist. Use PROACTIVELY when implementing new API endpoints, SDK operations, or adding new resource types. MUST BE USED for all new feature implementation work.
tools: Read, Edit, Write, Bash, Grep, Glob
model: sonnet
model: opus
---

You are an expert Python SDK developer specializing in building the Bloomy Growth API SDK. You have deep knowledge of the codebase patterns and conventions.
Expand Down
2 changes: 1 addition & 1 deletion .claude/agents/code-quality-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: code-quality-reviewer
description: Code quality and review specialist. Use PROACTIVELY after code changes to run quality gates and review for issues. MUST BE USED before creating PRs or after significant changes.
tools: Read, Bash, Grep, Glob, Edit
model: sonnet
model: opus
---

You are a senior code reviewer ensuring the Bloomy Python SDK maintains high standards of quality, type safety, and consistency.
Expand Down
2 changes: 1 addition & 1 deletion .claude/agents/mkdocs-documentation-writer.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: mkdocs-documentation-writer
description: Documentation specialist for MkDocs. Use PROACTIVELY when writing guides, updating README, creating API documentation, or documenting new features. MUST BE USED for all documentation tasks.
tools: Read, Write, Edit, Glob, Bash
model: sonnet
model: opus
---

You are an expert technical writer specializing in Python SDK documentation using MkDocs with Material theme.
Expand Down
2 changes: 1 addition & 1 deletion .claude/agents/sdk-test-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: sdk-test-engineer
description: Testing and debugging specialist for the SDK. Use PROACTIVELY to write tests, run test suites, fix failures, and improve coverage. MUST BE USED for all testing work.
tools: Read, Write, Edit, Bash, Grep, Glob
model: sonnet
model: opus
---

You are a test automation expert specializing in Python SDK testing with pytest.
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `client.v1` and `client.v2` API paths on `Client` and `AsyncClient`. `client.v1` holds the existing REST v1 operations; the top-level attributes (`client.user`, `client.issue`, ...) remain aliases of the same objects.
- `client.v2`: operations backed by Bloom Growth's GraphQL API (the API the web app uses), with sync and async classes for users, meetings, issues, headlines, to-dos, goals, milestones, and metrics with scores. It uses the same API key as v1.
- v2 descriptions: `notes` on create/update is stored as a note pad (as the web app does) and read back as plain text.
- v2 metric scores: `scores()`, `set_score()` (upsert per week/month/quarter; daily scores are edited in place), `update_score()` (value and cell note), and `clear_score()`.
- `graphql_url` parameter on `Client` and `AsyncClient` (defaults to the host of `base_url` plus `/graphql/`).
- `GraphQLError` exception (subclass of `APIError`) with the raw GraphQL `errors`.
- v2 pydantic models in `bloomy.v2.models`, with timezone-aware UTC datetimes; v2 date parameters accept `datetime`, `date`, unix seconds, or ISO 8601 strings.

## [0.21.0] - 2026-01-09

### Changed
Expand Down
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ The Bloomy Python SDK is organized as a client-based architecture where all API
- Initializes httpx client with authentication headers
- Creates instances of all operation classes
- Supports context manager protocol
- Exposes two namespaces sharing the same httpx client and API key: `client.v1` (the REST API; top-level attributes like `client.user`/`client.meeting` are aliases onto `client.v1.*`, kept for backward compatibility) and `client.v2` (the GraphQL API at `graphql_url`, defaulting to the scheme/host of `base_url` plus `/graphql/`) — see "v2 (GraphQL) Operations Pattern" below

2. **Configuration (`src/bloomy/configuration.py`)**:
- Manages API key from multiple sources (direct, env var, config file)
Expand All @@ -68,14 +69,22 @@ The Bloomy Python SDK is organized as a client-based architecture where all API
- Generic bulk operations logic is provided in base classes (`_validate_bulk_item`, `_process_bulk_sync`, `_process_bulk_async`)
- Operations are accessed via client attributes: `client.user`, `client.meeting`, etc.

4. **Models (`src/bloomy/models.py`)**:
4. **v2 (GraphQL) Operations Pattern (`src/bloomy/v2/`)**:
- `client.v2` is the GraphQL namespace, separate from `client.v1`'s REST namespace above; it covers the same entities (users, meetings, issues, headlines, todos, goals, milestones, metrics) through GraphQL documents instead of REST calls
- Transport and shared helpers live in `src/bloomy/v2/base.py`: module-level functions (`to_timestamp`, `default_due_date`, `compact`, `dig_nodes`, `merge_nodes`), shared field constants (`NOTES_FIELDS`, `USER_REF_FIELDS`, `MEETING_REF_FIELDS`, `MILESTONES_CONNECTION`), and methods on the base classes: `_mutate` (runs a mutation and checks its result), `_one` (reads one entity or raises "not found"), and `_notes_input` (creates the notes pad for a create/edit input). `AbstractGraphQLOperations` subclasses v1's `AbstractOperations`, so meeting_id/user_id checks use `_validate_mutual_exclusion`. One `UserIdCache` per client shares the current user's id across all v2 operations objects
- Models live in `src/bloomy/v2/models.py`. They inherit `GqlBaseModel`, which validates the raw camelCase keys (field names also work, so `model_dump()` round-trips) and fills `notes` from the notes fields, so operations return `Model.model_validate(raw_node)` with no per-entity transform code
- Unlike v1, each entity's GraphQL documents, input builders, and sync/async operations classes live together in one module under `src/bloomy/v2/operations/` (e.g. `operations/issue.py` holds `IssueOperationsMixin`, `IssueOperations`, and `AsyncIssueOperations` side by side), instead of v1's separate `operations/`, `operations/async_/`, and `operations/mixins/` directories
- Each entity module's mixin (`<Entity>OperationsMixin`) holds the GraphQL documents and static builders such as `_todo_list_request`, `_todos_from`, `_todo_create_input`, and `_todo_edit_fields`; the sync and async classes, which inherit it alongside `GraphQLOperations`/`AsyncGraphQLOperations`, only execute and return. Updates and state changes (archive/restore/solve/complete) go through a per-class `_edit(entity_id, fields, *, action)` helper that runs the edit mutation and re-reads the entity (milestones instead delegate `complete()` to `update()`)
- Operations are accessed via `client.v2.user`, `client.v2.issue`, etc.

5. **Models (`src/bloomy/models.py`)**:
- Pydantic models for type-safe API responses
- All models inherit from `BloomyBaseModel` with common config
- Field aliases map PascalCase API responses to snake_case Python attributes
- Reusable annotated types: `OptionalDatetime` and `OptionalFloat` using Pydantic's `BeforeValidator`
- Some models are type aliases for backward compatibility (e.g., `HeadlineListItem = HeadlineDetails`)

5. **API Endpoints**:
6. **API Endpoints**:
- Base URL: `https://app.bloomgrowth.com/api/v1`
- Authentication: Bearer token in Authorization header
- All responses are JSON
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,43 @@ client.headline.update(headline_id=456, title="Updated headline")
client.headline.delete(headline_id=456)
```

## v2: The GraphQL API

`client.v2` is a new namespace that talks to Bloom Growth's GraphQL API
instead of the REST API `client.v1` (and the top-level attributes above) use.
It shares the same client instance, the same API key, and covers the same
entities — users, meetings, issues, headlines, todos, goals, milestones, and
metrics — plus a few things v1 doesn't have, like milestones as their own
resource.

```python
from bloomy import Client

client = Client(api_key="your-api-key-here")

# v1 (REST) — unchanged
meetings = client.meeting.list()

# v2 (GraphQL) — new
meeting = client.v2.meeting.details(meetings[0].id)
issues = client.v2.issue.list(meeting.id)

issue = client.v2.issue.create(meeting.id, "New issue", notes="Details")
client.v2.issue.solve(issue.id)

goal = client.v2.goal.create(meeting.id, "Ship v2")
client.v2.milestone.create(goal.id, "Draft spec", due_date="2026-01-01")

metric = client.v2.metric.create(meeting.id, "New Customers", goal=10)
client.v2.metric.set_score(metric.id, 12, "2026-09-21")
```

v2 raises `GraphQLError` (a subclass of `APIError`) instead of a plain
`APIError`, and its models (`bloomy.v2.models`) are separate classes from the
v1 ones. See the [v2 guide](https://franccesco.github.io/bloomy-python/guide/v2-graphql-api/)
for the full tour, including description/notes behavior, archive-vs-delete
semantics per entity, and metric score/week semantics.

## Configuration

The SDK supports multiple ways to provide your API key:
Expand Down
139 changes: 139 additions & 0 deletions docs/api/v2/goal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Goal Operations (v2)

GraphQL operations for managing goals (rocks). See the [v2 guide](../../guide/v2-graphql-api.md)
for how `client.v2` relates to `client.v1`/`client.goal`, and for the
description ("notes") behavior shared across v2 entities. For working with a
goal's milestones individually, see [Milestone Operations](milestone.md).

## API Reference

::: bloomy.v2.operations.goal.GoalOperations
options:
show_source: false
show_root_heading: true
show_root_full_path: false
heading_level: 3

## Async Version

The async version `AsyncGoalOperations` provides the same methods as above, but with async/await support:

::: bloomy.v2.operations.goal.AsyncGoalOperations
options:
show_source: false
show_root_heading: true
show_root_full_path: false
heading_level: 3
members: false
show_bases: false
show_inheritance_diagram: false

!!! info "Async Usage"
All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call.

## Usage Examples

=== "Sync"

```python
from datetime import date
from bloomy import Client
from bloomy.v2.models import GoalStatus

with Client(api_key="your-api-key") as client:
# Create a goal (due date defaults to 90 days from today)
goal = client.v2.goal.create(
meeting_id=123,
title="Ship v2",
notes="Launch details",
)

# Create a goal with milestones in the same call
goal_with_milestones = client.v2.goal.create(
meeting_id=123,
title="Ship v2",
milestones=[
{"title": "Draft spec", "due_date": date(2026, 1, 1)},
("Beta release", date(2026, 2, 1)),
],
)

# Get goal details, including milestones
details = client.v2.goal.details(goal.id)
for milestone in details.milestones:
print(f"{milestone.title}: completed={milestone.completed}")

# List goals for a meeting
meeting_goals = client.v2.goal.list(meeting_id=123)

# List goals for the current user, including archived ones
my_goals = client.v2.goal.list(include_archived=True)

# Update status using the v2 enum (ON_TRACK / OFF_TRACK / COMPLETED)
updated = client.v2.goal.update(goal.id, status=GoalStatus.OFF_TRACK)

# Archive, then restore
client.v2.goal.archive(goal.id)
client.v2.goal.restore(goal.id)
```

=== "Async"

```python
import asyncio
from bloomy import AsyncClient
from bloomy.v2.models import GoalStatus

async def main():
async with AsyncClient(api_key="your-api-key") as client:
# Create a goal (due date defaults to 90 days from today)
goal = await client.v2.goal.create(
meeting_id=123,
title="Ship v2",
notes="Launch details",
)

# Get goal details, including milestones
details = await client.v2.goal.details(goal.id)

# Update status using the v2 enum (ON_TRACK / OFF_TRACK / COMPLETED)
updated = await client.v2.goal.update(goal.id, status=GoalStatus.OFF_TRACK)

# Archive, then restore
await client.v2.goal.archive(goal.id)
await client.v2.goal.restore(goal.id)

asyncio.run(main())
```

## Available Methods

| Method | Description | Parameters | Returns |
|--------|-------------|------------|---------|
| `list()` | List goals for a meeting or a user | `meeting_id`, `user_id`, `include_archived` | `list[Goal]` |
| `details()` | Get a goal, including its milestones | `goal_id` | `Goal` |
| `create()` | Create a goal, optionally with milestones | `meeting_id`, `title`, `user_id`, `due_date`, `status`, `notes`, `milestones` | `Goal` |
| `update()` | Update a goal | `goal_id`, `title`, `status`, `due_date`, `user_id`, `notes` | `Goal` |
| `archive()` | Archive a goal | `goal_id` | `Goal` |
| `restore()` | Restore an archived goal | `goal_id` | `Goal` |

!!! note "`GoalStatus` is different from the v1 enum"
`bloomy.v2.models.GoalStatus` (`ON_TRACK` / `OFF_TRACK` / `COMPLETED`) is a
distinct enum from `bloomy.GoalStatus` (v1's `on` / `off` / `complete`).
Import the v2 one from `bloomy.v2.models` when working with `client.v2.goal`.

!!! note "`milestones=` on `create()`"
Each item is either `{"title": ..., "due_date": ..., "completed": ...}`
(`completed` optional, defaults to `False`) or a `(title, due_date)` /
`(title, due_date, completed)` tuple. To add milestones to an existing
goal afterwards, use [`client.v2.milestone.create()`](milestone.md) instead.

!!! warning "`archive()` can raise `GraphQLError`"
The underlying `EditGoal(archived: true)` mutation can report success
without archiving anything, due to a server-side bug. `archive()` re-reads
the goal after the edit and raises `GraphQLError` if it is still not
archived, so a caught exception here reliably means the archive did not
take effect — it is safe to retry.

!!! note "Update requirements"
At least one field must be provided to `update()`, or it raises `ValueError`.
112 changes: 112 additions & 0 deletions docs/api/v2/headline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Headline Operations (v2)

GraphQL operations for managing headlines. See the [v2 guide](../../guide/v2-graphql-api.md)
for how `client.v2` relates to `client.v1`/`client.headline`, and for the
description ("notes") behavior shared across v2 entities.

## API Reference

::: bloomy.v2.operations.headline.HeadlineOperations
options:
show_source: false
show_root_heading: true
show_root_full_path: false
heading_level: 3

## Async Version

The async version `AsyncHeadlineOperations` provides the same methods as above, but with async/await support:

::: bloomy.v2.operations.headline.AsyncHeadlineOperations
options:
show_source: false
show_root_heading: true
show_root_full_path: false
heading_level: 3
members: false
show_bases: false
show_inheritance_diagram: false

!!! info "Async Usage"
All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call.

## Usage Examples

=== "Sync"

```python
from bloomy import Client

with Client(api_key="your-api-key") as client:
# Create a new headline
headline = client.v2.headline.create(
meeting_id=123,
title="Product launch successful",
notes="Exceeded targets by 15%",
)

# Get headline details
details = client.v2.headline.details(headline.id)

# List headlines for a meeting (open only, by default)
meeting_headlines = client.v2.headline.list(meeting_id=123)

# Include archived headlines too
all_headlines = client.v2.headline.list(meeting_id=123, include_archived=True)

# List headlines for the current user instead
my_headlines = client.v2.headline.list()

# Update the title
updated = client.v2.headline.update(headline.id, title="Updated title")

# Archive, then restore
client.v2.headline.archive(headline.id)
client.v2.headline.restore(headline.id)
```

=== "Async"

```python
import asyncio
from bloomy import AsyncClient

async def main():
async with AsyncClient(api_key="your-api-key") as client:
# Create a new headline
headline = await client.v2.headline.create(
meeting_id=123,
title="Product launch successful",
notes="Exceeded targets by 15%",
)

# List headlines for a meeting (open only, by default)
meeting_headlines = await client.v2.headline.list(meeting_id=123)

# Update the title
updated = await client.v2.headline.update(headline.id, title="Updated title")

# Archive, then restore
await client.v2.headline.archive(headline.id)
await client.v2.headline.restore(headline.id)

asyncio.run(main())
```

## Available Methods

| Method | Description | Parameters | Returns |
|--------|-------------|------------|---------|
| `details()` | Get a headline | `headline_id` | `Headline` |
| `list()` | List headlines for a meeting or a user | `meeting_id`, `user_id`, `include_archived` | `list[Headline]` |
| `create()` | Create a headline | `meeting_id`, `title`, `user_id`, `notes` | `Headline` |
| `update()` | Update a headline | `headline_id`, `title`, `user_id`, `notes` | `Headline` |
| `archive()` | Archive a headline | `headline_id` | `Headline` |
| `restore()` | Restore an archived headline | `headline_id` | `Headline` |

!!! note "Filtering"
`list()` accepts either `meeting_id` or `user_id`, not both. Passing both
raises `ValueError`. If neither is given, it defaults to the current user.

!!! note "Update requirements"
At least one field must be provided to `update()`, or it raises `ValueError`.
Loading
Loading