Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion examples/install_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
)
from wefunder._generated.api.syndicate_deals import list_syndicate_deals
from wefunder._generated.models.create_installation_body import CreateInstallationBody
from wefunder._generated.models.create_installation_token_body import CreateInstallationTokenBody


def example(wf: Wefunder, syndicate_id: str = "syn_abc123Example") -> Any:
Expand All @@ -41,7 +42,8 @@ def example(wf: Wefunder, syndicate_id: str = "syn_abc123Example") -> Any:
existing_id = err.details.get("installation")
if not existing_id:
raise
minted = wf.call(create_installation_token, external_id=existing_id)
mint = CreateInstallationTokenBody.from_dict({"scopes": ["read:syndicates"]}) # same scopes as above
minted = wf.call(create_installation_token, external_id=existing_id, body=mint)
installation_token = minted.token.access_token # shown once — store it

# 3. First request AS the installation.
Expand Down
2 changes: 1 addition & 1 deletion examples_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"getCurrentUser": "me = wf.users.me()\nprint(me.id, me.attributes.name)",
"getOffering": "try:\n offering = wf.offerings.get(offering_id)\nexcept WefunderError as err:\n # 404 → err.type == \"not_found\"; err.request_id is what support asks for.\n print(err.status, err.type, err.request_id)\n raise\nprint(offering.attributes.company_name, offering.attributes.status)",
"guides/client-credentials": "wf = Wefunder.from_client_credentials(\n client_id=os.environ[\"WEFUNDER_CLIENT_ID\"],\n client_secret=os.environ[\"WEFUNDER_CLIENT_SECRET\"],\n scopes=[\"read:public\"],\n)\n\nprint(\"mode:\", wf.mode) # \"live\" or \"test\", from the token prefix\n\n# client_credentials holds only read:public — browse public offerings.\n# (wf.users.me() would raise 403 insufficient_scope: it needs read:profile.)\nfor offering in wf.offerings.all():\n print(offering.id)",
"guides/install-target": "# 1. Which companies / syndicates may this user install on? (Only those — an investor's\n# empty list is not a failure.)\ntargets = wf.call(list_eligible_install_targets, target_type=\"syndicate\")\nfor target in targets.data:\n print(target.id, target.name, \"(already installed)\" if target.installed else \"\")\n\n# 2. Install. The response carries the install (`data`) AND its token. If the app is\n# already installed here the API answers 409 `already_installed` — its\n# `details[\"installation\"]` is the existing install's id, so mint a fresh token for that\n# instead. Any other error (revoked install, missing scope) still raises.\nbody = CreateInstallationBody.from_dict(\n {\"target_type\": \"syndicate\", \"target_id\": syndicate_id, \"scopes\": [\"read:syndicates\"]}\n)\ntry:\n installed = wf.call(create_installation, body=body)\n installation_token = installed.token.access_token\nexcept WefunderError as err:\n if err.type != \"already_installed\" or not isinstance(err.details, dict):\n raise\n existing_id = err.details.get(\"installation\")\n if not existing_id:\n raise\n minted = wf.call(create_installation_token, external_id=existing_id)\n installation_token = minted.token.access_token # shown once — store it\n\n# 3. First request AS the installation.\nas_syndicate = Wefunder(access_token=installation_token)\ndeals = as_syndicate.call(list_syndicate_deals, syndicate_id=syndicate_id)\nprint(len(deals.data), \"deals\")",
"guides/install-target": "# 1. Which companies / syndicates may this user install on? (Only those — an investor's\n# empty list is not a failure.)\ntargets = wf.call(list_eligible_install_targets, target_type=\"syndicate\")\nfor target in targets.data:\n print(target.id, target.name, \"(already installed)\" if target.installed else \"\")\n\n# 2. Install. The response carries the install (`data`) AND its token. If the app is\n# already installed here the API answers 409 `already_installed` — its\n# `details[\"installation\"]` is the existing install's id, so mint a fresh token for that\n# instead. Any other error (revoked install, missing scope) still raises.\nbody = CreateInstallationBody.from_dict(\n {\"target_type\": \"syndicate\", \"target_id\": syndicate_id, \"scopes\": [\"read:syndicates\"]}\n)\ntry:\n installed = wf.call(create_installation, body=body)\n installation_token = installed.token.access_token\nexcept WefunderError as err:\n if err.type != \"already_installed\" or not isinstance(err.details, dict):\n raise\n existing_id = err.details.get(\"installation\")\n if not existing_id:\n raise\n mint = CreateInstallationTokenBody.from_dict({\"scopes\": [\"read:syndicates\"]}) # same scopes as above\n minted = wf.call(create_installation_token, external_id=existing_id, body=mint)\n installation_token = minted.token.access_token # shown once — store it\n\n# 3. First request AS the installation.\nas_syndicate = Wefunder(access_token=installation_token)\ndeals = as_syndicate.call(list_syndicate_deals, syndicate_id=syndicate_id)\nprint(len(deals.data), \"deals\")",
"guides/user-token": "# 1. Send the investor to consent. Keep state + the PKCE verifier in their session.\npkce = generate_pkce()\nstate = secrets.token_urlsafe(32)\nsave_attempt(state, pkce.code_verifier)\nauthorization_url = create_authorization_url(\n client_id=os.environ[\"WEFUNDER_CLIENT_ID\"],\n redirect_uri=os.environ[\"WEFUNDER_REDIRECT_URI\"],\n scopes=[\"read:investments\"],\n state=state,\n pkce=pkce,\n)\n# redirect(authorization_url)\n\n# 2. On the callback, exchange the code (after checking `state` matches the session).\ntokens = exchange_code(\n client_id=os.environ[\"WEFUNDER_CLIENT_ID\"],\n client_secret=os.environ.get(\"WEFUNDER_CLIENT_SECRET\"), # omit for public clients\n code=\"AUTHORIZATION_CODE\",\n redirect_uri=os.environ[\"WEFUNDER_REDIRECT_URI\"],\n code_verifier=pkce.code_verifier,\n)\n\n# 3. Read their holdings. The access token lasts two hours; the SDK rotates the refresh\n# token for you and hands every new set to `store.save` — persist the whole thing.\nwf = Wefunder(\n tokens=tokens,\n client_id=os.environ[\"WEFUNDER_CLIENT_ID\"],\n client_secret=os.environ.get(\"WEFUNDER_CLIENT_SECRET\"),\n store=TokenStore(),\n)\nportfolio = wf.portfolio.get()\nprint(portfolio.attributes.total_current_value_cents)",
"guides/webhook-receiver": "try:\n event = construct_event(raw_body, headers, secret)\nexcept WebhookSignatureError as err:\n return 400, err.reason # not authentic — don't process\n\n# Acknowledge first, then do the work. Deliveries are at-least-once and unordered:\n# deduplicate on event.id and, where a payload has occurred_at, keep the latest state.\ndef record_funding(e: WebhookEvent) -> None:\n print(\"funded\", e.data[\"id\"], e.data[\"amounts\"][\"committed\"])\n\ndispatch_webhook(\n event,\n {\n \"investment.executed\": record_funding,\n \"offering.opened\": lambda e: print(\"opened\", e.data[\"company\"][\"name\"]),\n \"default\": lambda e: print(\"unhandled\", e.event),\n },\n)",
"listCompanyInvestmentChanges": "# Walk every change for one company. Persist meta.next_cursor after each page — it is\n# always present, even on the last page — and pass it back next sync for only what changed.\ncursor: str | None = None\nwhile True:\n page = wf.call(list_company_investment_changes, company_id=company_id, cursor=cursor, per_page=50)\n for record in page.data:\n print(record.id, record.visible)\n cursor = page.meta.next_cursor\n if not page.meta.has_more:\n break",
Expand Down
3 changes: 2 additions & 1 deletion src/wefunder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
refresh_token,
)
from .pagination import acollect, apaginate, collect, paginate
from .token_manager import AsyncTokenManager, TokenManager, TokenStore
from .token_manager import AsyncTokenManager, TokenManager, TokenStore, WefunderTokenPersistenceError
from .webhooks import (
DEFAULT_TOLERANCE_SECONDS,
SIGNATURE_HEADER,
Expand Down Expand Up @@ -79,6 +79,7 @@
"Wefunder",
"WefunderAuthError",
"WefunderError",
"WefunderTokenPersistenceError",
"__version__",
"acollect",
"apaginate",
Expand Down
7 changes: 7 additions & 0 deletions src/wefunder/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ def from_client_credentials(
oauth_base_url=kwargs.get("oauth_base_url"),
transport=kwargs.get("transport"),
now=kwargs.get("now"),
timeout=kwargs.get("timeout", 30.0),
)
return cls(
tokens=tokens,
Expand All @@ -269,6 +270,11 @@ def tokens(self) -> TokenSet:
"""The current token set (rotated refresh token included)."""
return self._tm.current

@property
def token_manager(self) -> TokenManager:
"""The :class:`TokenManager` (for ``mark_persisted()`` after a :class:`WefunderTokenPersistenceError`)."""
return self._tm

@property
def raw(self) -> AuthenticatedClient:
"""The generated client, for any operation without a namespace::
Expand Down Expand Up @@ -569,6 +575,7 @@ async def from_client_credentials(
oauth_base_url=kwargs.get("oauth_base_url"),
transport=kwargs.get("transport"),
now=kwargs.get("now"),
timeout=kwargs.get("timeout", 30.0),
)
return cls(
tokens=tokens,
Expand Down
30 changes: 22 additions & 8 deletions src/wefunder/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,19 +155,27 @@ def _parse_token_response(response: httpx.Response, now: float) -> TokenSet:


def _post_token(
base: str, params: Mapping[str, str], transport: httpx.BaseTransport | None, now: Now | None
base: str,
params: Mapping[str, str],
transport: httpx.BaseTransport | None,
now: Now | None,
timeout: httpx.Timeout | float | None = 30.0,
) -> TokenSet:
url, headers, data = _token_request(base, params)
with httpx.Client(transport=transport) as http:
with httpx.Client(transport=transport, timeout=timeout) as http:
response = http.post(url, headers=headers, data=data)
return _parse_token_response(response, (now or time.time)())


async def _apost_token(
base: str, params: Mapping[str, str], transport: httpx.AsyncBaseTransport | None, now: Now | None
base: str,
params: Mapping[str, str],
transport: httpx.AsyncBaseTransport | None,
now: Now | None,
timeout: httpx.Timeout | float | None = 30.0,
) -> TokenSet:
url, headers, data = _token_request(base, params)
async with httpx.AsyncClient(transport=transport) as http:
async with httpx.AsyncClient(transport=transport, timeout=timeout) as http:
response = await http.post(url, headers=headers, data=data)
return _parse_token_response(response, (now or time.time)())

Expand Down Expand Up @@ -212,6 +220,7 @@ def exchange_code(
oauth_base_url: str | None = None,
transport: httpx.BaseTransport | None = None,
now: Now | None = None,
timeout: httpx.Timeout | float | None = 30.0,
) -> TokenSet:
"""Exchange an authorization code (+ PKCE verifier) for a token set. Public (PKCE)
clients omit ``client_secret``; confidential clients pass it."""
Expand All @@ -230,10 +239,11 @@ def client_credentials_grant(
oauth_base_url: str | None = None,
transport: httpx.BaseTransport | None = None,
now: Now | None = None,
timeout: httpx.Timeout | float | None = 30.0,
) -> TokenSet:
"""Mint an application token (server-to-server). cc tokens carry no refresh token."""
base = resolve_token_base(token_base_url=token_base_url, oauth_base_url=oauth_base_url)
return _post_token(base, _cc_params(client_id, client_secret, scopes), transport, now)
return _post_token(base, _cc_params(client_id, client_secret, scopes), transport, now, timeout)


def refresh_token(
Expand All @@ -245,11 +255,12 @@ def refresh_token(
oauth_base_url: str | None = None,
transport: httpx.BaseTransport | None = None,
now: Now | None = None,
timeout: httpx.Timeout | float | None = 30.0,
) -> TokenSet:
"""Refresh an access token. CRITICAL: the result carries a NEW refresh token
(rotation) — persist it. Reusing the old one after rotation is a permanent 401."""
base = resolve_token_base(token_base_url=token_base_url, oauth_base_url=oauth_base_url)
return _post_token(base, _refresh_params(client_id, client_secret, refresh_token), transport, now)
return _post_token(base, _refresh_params(client_id, client_secret, refresh_token), transport, now, timeout)


async def async_exchange_code(
Expand All @@ -263,6 +274,7 @@ async def async_exchange_code(
oauth_base_url: str | None = None,
transport: httpx.AsyncBaseTransport | None = None,
now: Now | None = None,
timeout: httpx.Timeout | float | None = 30.0,
) -> TokenSet:
"""Async :func:`exchange_code`."""
base = resolve_token_base(token_base_url=token_base_url, oauth_base_url=oauth_base_url)
Expand All @@ -280,10 +292,11 @@ async def async_client_credentials_grant(
oauth_base_url: str | None = None,
transport: httpx.AsyncBaseTransport | None = None,
now: Now | None = None,
timeout: httpx.Timeout | float | None = 30.0,
) -> TokenSet:
"""Async :func:`client_credentials_grant`."""
base = resolve_token_base(token_base_url=token_base_url, oauth_base_url=oauth_base_url)
return await _apost_token(base, _cc_params(client_id, client_secret, scopes), transport, now)
return await _apost_token(base, _cc_params(client_id, client_secret, scopes), transport, now, timeout)


async def async_refresh_token(
Expand All @@ -295,7 +308,8 @@ async def async_refresh_token(
oauth_base_url: str | None = None,
transport: httpx.AsyncBaseTransport | None = None,
now: Now | None = None,
timeout: httpx.Timeout | float | None = 30.0,
) -> TokenSet:
"""Async :func:`refresh_token`."""
base = resolve_token_base(token_base_url=token_base_url, oauth_base_url=oauth_base_url)
return await _apost_token(base, _refresh_params(client_id, client_secret, refresh_token), transport, now)
return await _apost_token(base, _refresh_params(client_id, client_secret, refresh_token), transport, now, timeout)
Loading
Loading