Skip to content

Commit 44ad556

Browse files
authored
feat(flags): feature-flag system with sdk.flags and operation gating (#59)
1 parent 1a6c672 commit 44ad556

214 files changed

Lines changed: 9156 additions & 3656 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
types: [opened, synchronize, reopened, ready_for_review, draft]
66
paths:
77
- 'src/codesphere/**'
8+
- 'scripts/**'
89
- '.github/workflows/ci.yml'
910
- 'tests/**'
1011
- 'pyproject.toml'
@@ -39,6 +40,15 @@ jobs:
3940
run: uv run ty check
4041
shell: bash
4142

43+
- name: Verify generated sync client is up to date
44+
run: |
45+
uv run python scripts/gen_sync.py
46+
git diff --exit-code --stat src/codesphere/_sync || {
47+
echo "::error::src/codesphere/_sync is stale. Run 'make sync-gen' and commit the result."
48+
exit 1
49+
}
50+
shell: bash
51+
4252
- name: Minimize uv cache
4353
run: uv cache prune --ci
4454

.github/workflows/docs.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Docs
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
pages: write
11+
id-token: write
12+
13+
concurrency:
14+
group: pages
15+
cancel-in-progress: false
16+
17+
jobs:
18+
build:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- name: Install uv
24+
uses: astral-sh/setup-uv@v5
25+
26+
- name: Build docs
27+
run: |
28+
uv sync --group docs
29+
uv run mkdocs build --strict
30+
31+
- name: Upload pages artifact
32+
uses: actions/upload-pages-artifact@v3
33+
with:
34+
path: site
35+
36+
deploy:
37+
needs: build
38+
runs-on: ubuntu-latest
39+
environment:
40+
name: github-pages
41+
url: ${{ steps.deployment.outputs.page_url }}
42+
steps:
43+
- name: Deploy to GitHub Pages
44+
id: deployment
45+
uses: actions/deploy-pages@v4

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ __marimo__
2525
.coverage
2626
coverage.xml
2727
test-results/
28+
# MkDocs build output
29+
site/

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,9 @@ repos:
2020
language: system
2121
types: [python]
2222
pass_filenames: false
23+
- id: sync-gen
24+
name: regenerate sync client
25+
entry: uv run python scripts/gen_sync.py
26+
language: system
27+
files: ^(src/codesphere/_async/|src/codesphere/_sync/|scripts/gen_sync\.py|pyproject\.toml)
28+
pass_filenames: false

CHANGELOG.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,38 @@
1+
## Unreleased
2+
3+
### Added
4+
5+
- Synchronous client: `from codesphere.sync import CodesphereSDK` mirrors
6+
the async API with identical signatures (generated from the async
7+
source via unasyncd; parity is enforced in CI). Request/response
8+
models are shared between both flavors via the new `codesphere.models`
9+
package.
10+
- Per-call timeout: every API method now accepts a keyword-only
11+
`timeout=` (float or `httpx.Timeout`) that overrides the client-wide
12+
timeout for that call only (applied per retry attempt).
13+
- Hosted documentation site (mkdocs-material + mkdocstrings) with
14+
quickstart, guides, full API reference, and `llms.txt`; deployed to
15+
GitHub Pages on pushes to `main`.
16+
17+
### Changed
18+
19+
- Retries on transient failures are now enabled by default
20+
(`max_retries=2`). Idempotent methods (`GET`, `HEAD`, `PUT`, `DELETE`)
21+
are retried on `429`/`502`/`503`/`504` and connect/timeout errors.
22+
Set `RetryConfig(max_retries=0)` to restore the previous opt-in
23+
behavior. `POST` is still never retried unless explicitly added to
24+
`retry_methods`.
25+
- Debug/error logs no longer include request or response bodies (they can
26+
contain secrets such as env-var values); only structural information is
27+
logged.
28+
- Package classifiers: `Development Status :: 5 - Production/Stable` and
29+
`Typing :: Typed`.
30+
31+
### Removed
32+
33+
- The deprecated module path `codesphere.resources.workspace.envVars`
34+
(use `codesphere.resources.workspace.env_vars`).
35+
136
## v1.0.0 (2026-02-21)
237

338
### Features
@@ -7,10 +42,10 @@
742
- feat(git): add git resource (#45)
843
- feat(landscape): add landscape resource (#41)
944
- feat(model-export): provide model methods to export data (#40)
10-
- feat(excetions): add base exceptions (#39)
45+
- feat(exceptions): add base exceptions (#39)
1146
- feat(agent): add copilot instructions (#35)
1247
- feat(domain): add domains resource (#33)
13-
- feat(workspace): impleöment base operations for workspaces (#32)
48+
- feat(workspace): implement base operations for workspaces (#32)
1449

1550
### Refactors
1651

@@ -25,7 +60,7 @@
2560
- refac(commit-hook): remove commitizen (#38)
2661
- test: fix ci
2762
- test(suite): set up initial testsuite (#36)
28-
- chore(test): bla bla (#28)
63+
- chore(test): test maintenance (#28)
2964
- chore(types): clean types annotations (#10)
3065

3166
## v0.4.0 (2025-07-22)
@@ -44,7 +79,7 @@
4479

4580
### Fix
4681

47-
- **dsd**: sdfsf
82+
- internal CI fixes
4883

4984
## v0.2.2 (2025-07-21)
5085

CONTRIBUTING.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,56 @@ versions.
129129
130130
---
131131
132+
## Gating an Endpoint Behind a Feature Flag
133+
134+
Platform instances enable features via flags (`GET /ide-service/flags`,
135+
three categories: `internal`, `preview`, `features`). If an SDK feature only
136+
works when a flag is enabled, declare it on the operation — **always with an
137+
explicit category**:
138+
139+
```python
140+
from ...core.operations import APIOperation
141+
from ...feature_flags import FlagCategory, FlagRequirement
142+
143+
_LIST_VPN_CONFIGS_OP = APIOperation(
144+
method="GET",
145+
endpoint_template="/vpn/configs",
146+
response_model=ResourceList[VpnConfig],
147+
required_flag=FlagRequirement("vpn", FlagCategory.INTERNAL),
148+
)
149+
```
150+
151+
Nothing else is needed: `execute_operation` checks the flag **before any
152+
request** and raises `FeatureNotAvailableError` (flag missing on this
153+
instance) or `FeatureNotEnabledError` (available but off). The flags
154+
snapshot is fetched once per client and cached; `await sdk.flags.refresh()`
155+
re-fetches. Instances without the flags endpoint fail closed.
156+
157+
For code paths that don't go through an `APIOperation` (SSE streams,
158+
shell-command features), check explicitly:
159+
160+
```python
161+
await self._require_flag(FlagRequirement("workspace-ssh", FlagCategory.PREVIEW))
162+
```
163+
164+
For async generators, perform this check in a plain `async def` factory
165+
*before* returning the generator, so the call fails fast instead of at the
166+
first iteration.
167+
168+
**Testing a gated endpoint:** register a respx route for
169+
`/ide-service/flags` alongside the endpoint route, and assert the endpoint
170+
route was **not** called when the flag is disabled:
171+
172+
```python
173+
mock_api.get("/ide-service/flags").respond(200, json={"preview": {"available": ["x"], "enabled": []}})
174+
route = mock_api.get("/gated").respond(200, json={})
175+
with pytest.raises(FeatureNotEnabledError):
176+
await resource.gated_call()
177+
assert route.called is False
178+
```
179+
180+
---
181+
132182
## Testing Guidelines
133183
134184
We maintain two types of tests: **unit tests** and **integration tests**. When contributing, please ensure appropriate test coverage for your changes.

Makefile

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help install lint format typecheck test test-integration test-unit bump release pypi tree version changelog
1+
.PHONY: help install lint format typecheck test test-integration test-unit bump release pypi tree version changelog docs docs-build sync-gen sync-check
22

33
.DEFAULT_GOAL := help
44

@@ -61,7 +61,7 @@ bump: ## Bumps the version. Usage: make bump VERSION=0.5.0
6161
exit 1; \
6262
fi
6363
@echo ">>> Bumping version from $(CURRENT_VERSION) to $(VERSION)..."
64-
@sed -i '' 's/^version = ".*"/version = "$(VERSION)"/' pyproject.toml
64+
@sed -i.bak 's/^version = ".*"/version = "$(VERSION)"/' pyproject.toml && rm -f pyproject.toml.bak
6565
@echo ">>> Updating lockfile..."
6666
uv lock
6767
@echo "\033[0;32mVersion updated to $(VERSION) in pyproject.toml\033[0m"
@@ -144,5 +144,19 @@ pypi: ## Builds and publishes to PyPI (usually called by CI)
144144
uv publish
145145
@echo "\n\033[0;32mPyPI release complete!\033[0m"
146146

147+
sync-gen: ## Regenerates the sync client (src/codesphere/_sync) from the async source
148+
uv run python scripts/gen_sync.py
149+
150+
sync-check: ## Verifies the generated sync client is up to date with the async source
151+
uv run python scripts/gen_sync.py
152+
@git diff --exit-code --stat src/codesphere/_sync \
153+
|| (echo "\033[0;31mERROR: src/codesphere/_sync is stale. Run 'make sync-gen' and commit the result.\033[0m"; exit 1)
154+
155+
docs: ## Serves the documentation locally with live reload
156+
uv run --group docs mkdocs serve
157+
158+
docs-build: ## Builds the documentation (strict: fails on broken references)
159+
uv run --group docs mkdocs build --strict
160+
147161
tree: ## Shows filetree in terminal without uninteresting files
148162
tree -I "*.pyc|*.lock"

README.md

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,28 +60,47 @@ any environment variable).
6060

6161
### Retries
6262

63-
Retries are off by default. Opt in with a `RetryConfig`:
63+
Transient failures are retried automatically (2 retries by default).
64+
Requests using an idempotent method (`GET`, `HEAD`, `PUT`, `DELETE`) are
65+
retried on `429`, `502`, `503`, and `504` responses as well as on
66+
connect/timeout errors. The delay honors the server's `Retry-After` header
67+
(seconds or HTTP-date) when present, otherwise exponential backoff with
68+
jitter (`backoff_factor * 2**attempt`).
69+
70+
Tune or disable the behavior with a `RetryConfig`:
6471

6572
```python
6673
from codesphere import CodesphereSDK, RetryConfig
6774

6875
sdk = CodesphereSDK(
6976
token="your-api-token",
70-
retry=RetryConfig(max_retries=3),
77+
retry=RetryConfig(max_retries=0), # disable retries entirely
7178
)
7279
```
7380

74-
Requests using an idempotent method (`GET`, `HEAD`, `PUT`, `DELETE`) are then
75-
retried on `429`, `502`, `503`, and `504` responses as well as on
76-
connect/timeout errors. The delay honors the server's `Retry-After` header
77-
(seconds or HTTP-date) when present, otherwise exponential backoff with
78-
jitter (`backoff_factor * 2**attempt`). `POST` is never retried unless you
79-
add it to `retry_methods` explicitly:
81+
`POST` is never retried unless you add it to `retry_methods` explicitly
82+
(only do this if your endpoints tolerate duplicate submissions):
8083

8184
```python
8285
RetryConfig(max_retries=3, retry_methods=frozenset({"GET", "POST"}))
8386
```
8487

88+
### Feature flags
89+
90+
Platform instances enable different features. Inspect them via `sdk.flags`:
91+
92+
```python
93+
snapshot = await sdk.flags.get() # fetched once, then cached
94+
if await sdk.flags.is_enabled("workspace-ssh"):
95+
...
96+
97+
await sdk.flags.refresh() # re-fetch after platform changes
98+
```
99+
100+
SDK features that depend on a platform flag raise `FeatureNotEnabledError`
101+
(or `FeatureNotAvailableError` if the instance doesn't have the feature at
102+
all) *before* sending any request when the flag is off.
103+
85104
## Quick Start
86105

87106
```python
@@ -97,6 +116,24 @@ async def main():
97116
asyncio.run(main())
98117
```
99118

119+
### Synchronous client
120+
121+
The same API is available without `async`/`await` — ideal for scripts and
122+
tools that don't run an event loop:
123+
124+
```python
125+
from codesphere.sync import CodesphereSDK
126+
127+
with CodesphereSDK() as sdk:
128+
teams = sdk.teams.list()
129+
for team in teams:
130+
print(f"{team.name} (ID: {team.id})")
131+
```
132+
133+
Both flavors share the same request/response models, configuration, and
134+
error types; only the entities with API methods (`Workspace`, `Team`, …)
135+
exist per flavor.
136+
100137
## Usage
101138

102139
### Teams

docs/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Changelog
2+
3+
--8<-- "CHANGELOG.md"

docs/guides/configuration.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Configuration
2+
3+
Every option can be passed directly to the constructor:
4+
5+
```python
6+
from codesphere import CodesphereSDK
7+
8+
sdk = CodesphereSDK(token="your-api-token")
9+
# or, e.g. against a different deployment:
10+
sdk = CodesphereSDK(
11+
token="your-api-token",
12+
base_url="https://my-instance.example.com/api",
13+
)
14+
```
15+
16+
Alternatively, configure via environment variables (or a `.env` file in your
17+
project root). Explicit constructor arguments always win over the environment:
18+
19+
| Variable | Description | Default |
20+
|----------|-------------|---------|
21+
| `CS_TOKEN` | API token (required) ||
22+
| `CS_BASE_URL` | API base URL | `https://codesphere.com/api` |
23+
| `CS_CLIENT_TIMEOUT_CONNECT` | Connect timeout in seconds | `10.0` |
24+
| `CS_CLIENT_TIMEOUT_READ` | Read timeout in seconds | `30.0` |
25+
26+
If no token is provided at all, `CodesphereSDK()` raises an
27+
`AuthenticationError` at construction (importing the package never requires
28+
any environment variable).
29+
30+
## Client lifecycle
31+
32+
The client owns an HTTP connection pool. Use it as an async context manager
33+
(recommended) or open/close it explicitly:
34+
35+
```python
36+
async with CodesphereSDK() as sdk:
37+
...
38+
39+
# or
40+
sdk = CodesphereSDK()
41+
await sdk.open()
42+
try:
43+
...
44+
finally:
45+
await sdk.close()
46+
```
47+
48+
## Logging
49+
50+
The SDK logs to the `codesphere` logger (a `NullHandler` is installed by
51+
default). Enable debug output to see requests, responses, and retries:
52+
53+
```python
54+
import logging
55+
56+
logging.getLogger("codesphere").setLevel(logging.DEBUG)
57+
logging.basicConfig()
58+
```
59+
60+
Request and response bodies are never logged — payloads such as env-var
61+
values can contain secrets.

0 commit comments

Comments
 (0)