Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
timeout-minutes: 10
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/hyperspell-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
steps:
- uses: actions/checkout@v6

Expand All @@ -35,7 +35,7 @@ jobs:
run: ./scripts/lint

build:
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
timeout-minutes: 10
name: build
permissions:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.prism.log
.stdy.log
_dev

__pycache__
Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.36.0"
".": "0.37.0"
}
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 30
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-b1f2b7cb843e6f4e6123e838ce29cbbaea0a48b1a72057632de1d0d21727c5d8.yml
openapi_spec_hash: 21a354f587a2fe19797860c7b6da81a9
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-7d29d0843a52840291678a3c6d136f496ae1f956853abaa5003c1284ca2c94aa.yml
openapi_spec_hash: 010597ad0ec6376fbf2f01ec062787ad
config_hash: 0ed970a9634b33d0af471738b478740d
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Changelog

## 0.37.0 (2026-04-01)

Full Changelog: [v0.36.0...v0.37.0](https://github.com/hyperspell/python-sdk/compare/v0.36.0...v0.37.0)

### Features

* **api:** api update ([13e0682](https://github.com/hyperspell/python-sdk/commit/13e068243400026b7c8e11b11fdca6ba89a2460e))
* **api:** api update ([a0f4720](https://github.com/hyperspell/python-sdk/commit/a0f47209c51eb35cf31974bc158cf9e4df29c0ec))
* **api:** api update ([d3e77c3](https://github.com/hyperspell/python-sdk/commit/d3e77c368350f9a22469db6574d3a44159a043c9))
* **api:** api update ([6180902](https://github.com/hyperspell/python-sdk/commit/618090275519b615d2f0b4679aecb9a86ccbab0b))
* **internal:** implement indices array format for query and form serialization ([0e1fd91](https://github.com/hyperspell/python-sdk/commit/0e1fd91815afe92b4b7ba73f96e196aa71d5f8c5))


### Bug Fixes

* sanitize endpoint path params ([b2b3ca2](https://github.com/hyperspell/python-sdk/commit/b2b3ca2749930db6558bbe5bc3bb1ce4adadfe0e))


### Chores

* **ci:** skip lint on metadata-only changes ([b5a6b2d](https://github.com/hyperspell/python-sdk/commit/b5a6b2d8467b72cc0bfa192cf2bfd2966de6190e))
* **internal:** update gitignore ([8f27c46](https://github.com/hyperspell/python-sdk/commit/8f27c46407285a62b885632d2bb3c6059ec91eb2))
* **tests:** bump steady to v0.19.4 ([a855b0a](https://github.com/hyperspell/python-sdk/commit/a855b0a42e5926bdb4c1ece32f74370f9b481241))
* **tests:** bump steady to v0.19.5 ([7ea97c0](https://github.com/hyperspell/python-sdk/commit/7ea97c0c00ee4d81301a6b2214ed677e88a5fcd8))
* **tests:** bump steady to v0.19.6 ([1ecd0bf](https://github.com/hyperspell/python-sdk/commit/1ecd0bfd11ca3de3436524145c5df5c20b2ec2eb))
* **tests:** bump steady to v0.19.7 ([466a814](https://github.com/hyperspell/python-sdk/commit/466a8145d00c3810bd1d83ac8531c719377100b8))
* **tests:** bump steady to v0.20.1 ([74c3b3b](https://github.com/hyperspell/python-sdk/commit/74c3b3b7867b0fef233c314c776c5d867f66937a))
* **tests:** bump steady to v0.20.2 ([3eb71a2](https://github.com/hyperspell/python-sdk/commit/3eb71a2ea44943f9b2e10073292d7fd4d58c1b43))


### Refactors

* **tests:** switch from prism to steady ([441e854](https://github.com/hyperspell/python-sdk/commit/441e854a4933f7abd07961f08a58d7f7b7b3ea60))

## 0.36.0 (2026-03-18)

Full Changelog: [v0.35.0...v0.36.0](https://github.com/hyperspell/python-sdk/compare/v0.35.0...v0.36.0)
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ $ pip install ./path-to-wheel-file.whl

## Running tests

Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests.
Most tests require you to [set up a mock server](https://github.com/dgellow/steady) against the OpenAPI spec to run the tests.

```sh
$ ./scripts/mock
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "hyperspell"
version = "0.36.0"
version = "0.37.0"
description = "The official Python library for the hyperspell API"
dynamic = ["readme"]
license = "MIT"
Expand Down
26 changes: 13 additions & 13 deletions scripts/mock
Original file line number Diff line number Diff line change
Expand Up @@ -19,34 +19,34 @@ fi

echo "==> Starting mock server with URL ${URL}"

# Run prism mock on the given spec
# Run steady mock on the given spec
if [ "$1" == "--daemon" ]; then
# Pre-install the package so the download doesn't eat into the startup timeout
npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version
npm exec --package=@stdy/cli@0.20.2 -- steady --version

npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log &
npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log &

# Wait for server to come online (max 30s)
# Wait for server to come online via health endpoint (max 30s)
echo -n "Waiting for server"
attempts=0
while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do
while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do
if ! kill -0 $! 2>/dev/null; then
echo
cat .stdy.log
exit 1
fi
attempts=$((attempts + 1))
if [ "$attempts" -ge 300 ]; then
echo
echo "Timed out waiting for Prism server to start"
cat .prism.log
echo "Timed out waiting for Steady server to start"
cat .stdy.log
exit 1
fi
echo -n "."
sleep 0.1
done

if grep -q "✖ fatal" ".prism.log"; then
cat .prism.log
exit 1
fi

echo
else
npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL"
npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL"
fi
16 changes: 8 additions & 8 deletions scripts/test
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color

function prism_is_running() {
curl --silent "http://localhost:4010" >/dev/null 2>&1
function steady_is_running() {
curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1
}

kill_server_on_port() {
Expand All @@ -25,7 +25,7 @@ function is_overriding_api_base_url() {
[ -n "$TEST_API_BASE_URL" ]
}

if ! is_overriding_api_base_url && ! prism_is_running ; then
if ! is_overriding_api_base_url && ! steady_is_running ; then
# When we exit this script, make sure to kill the background mock server process
trap 'kill_server_on_port 4010' EXIT

Expand All @@ -36,19 +36,19 @@ fi
if is_overriding_api_base_url ; then
echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}"
echo
elif ! prism_is_running ; then
echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server"
elif ! steady_is_running ; then
echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server"
echo -e "running against your OpenAPI spec."
echo
echo -e "To run the server, pass in the path or url of your OpenAPI"
echo -e "spec to the prism command:"
echo -e "spec to the steady command:"
echo
echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}"
echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}"
echo

exit 1
else
echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}"
echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}"
echo
fi

Expand Down
5 changes: 4 additions & 1 deletion src/hyperspell/_qs.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ def _stringify_item(
items.extend(self._stringify_item(key, item, opts))
return items
elif array_format == "indices":
raise NotImplementedError("The array indices format is not supported yet")
items = []
for i, item in enumerate(value):
items.extend(self._stringify_item(f"{key}[{i}]", item, opts))
return items
elif array_format == "brackets":
items = []
key = key + "[]"
Expand Down
1 change: 1 addition & 0 deletions src/hyperspell/_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from ._path import path_template as path_template
from ._sync import asyncify as asyncify
from ._proxy import LazyProxy as LazyProxy
from ._utils import (
Expand Down
127 changes: 127 additions & 0 deletions src/hyperspell/_utils/_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from __future__ import annotations

import re
from typing import (
Any,
Mapping,
Callable,
)
from urllib.parse import quote

# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E).
_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$")

_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")


def _quote_path_segment_part(value: str) -> str:
"""Percent-encode `value` for use in a URI path segment.

Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe.
https://datatracker.ietf.org/doc/html/rfc3986#section-3.3
"""
# quote() already treats unreserved characters (letters, digits, and -._~)
# as safe, so we only need to add sub-delims, ':', and '@'.
# Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted.
return quote(value, safe="!$&'()*+,;=:@")


def _quote_query_part(value: str) -> str:
"""Percent-encode `value` for use in a URI query string.

Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe.
https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
"""
return quote(value, safe="!$'()*+,;:@/?")


def _quote_fragment_part(value: str) -> str:
"""Percent-encode `value` for use in a URI fragment.

Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe.
https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
"""
return quote(value, safe="!$&'()*+,;=:@/?")


def _interpolate(
template: str,
values: Mapping[str, Any],
quoter: Callable[[str], str],
) -> str:
"""Replace {name} placeholders in `template`, quoting each value with `quoter`.

Placeholder names are looked up in `values`.

Raises:
KeyError: If a placeholder is not found in `values`.
"""
# re.split with a capturing group returns alternating
# [text, name, text, name, ..., text] elements.
parts = _PLACEHOLDER_RE.split(template)

for i in range(1, len(parts), 2):
name = parts[i]
if name not in values:
raise KeyError(f"a value for placeholder {{{name}}} was not provided")
val = values[name]
if val is None:
parts[i] = "null"
elif isinstance(val, bool):
parts[i] = "true" if val else "false"
else:
parts[i] = quoter(str(values[name]))

return "".join(parts)


def path_template(template: str, /, **kwargs: Any) -> str:
"""Interpolate {name} placeholders in `template` from keyword arguments.

Args:
template: The template string containing {name} placeholders.
**kwargs: Keyword arguments to interpolate into the template.

Returns:
The template with placeholders interpolated and percent-encoded.

Safe characters for percent-encoding are dependent on the URI component.
Placeholders in path and fragment portions are percent-encoded where the `segment`
and `fragment` sets from RFC 3986 respectively are considered safe.
Placeholders in the query portion are percent-encoded where the `query` set from
RFC 3986 §3.3 is considered safe except for = and & characters.

Raises:
KeyError: If a placeholder is not found in `kwargs`.
ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments).
"""
# Split the template into path, query, and fragment portions.
fragment_template: str | None = None
query_template: str | None = None

rest = template
if "#" in rest:
rest, fragment_template = rest.split("#", 1)
if "?" in rest:
rest, query_template = rest.split("?", 1)
path_template = rest

# Interpolate each portion with the appropriate quoting rules.
path_result = _interpolate(path_template, kwargs, _quote_path_segment_part)

# Reject dot-segments (. and ..) in the final assembled path. The check
# runs after interpolation so that adjacent placeholders or a mix of static
# text and placeholders that together form a dot-segment are caught.
# Also reject percent-encoded dot-segments to protect against incorrectly
# implemented normalization in servers/proxies.
for segment in path_result.split("/"):
if _DOT_SEGMENT_RE.match(segment):
raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed")

result = path_result
if query_template is not None:
result += "?" + _interpolate(query_template, kwargs, _quote_query_part)
if fragment_template is not None:
result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part)

return result
2 changes: 1 addition & 1 deletion src/hyperspell/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "hyperspell"
__version__ = "0.36.0" # x-release-please-version
__version__ = "0.37.0" # x-release-please-version
4 changes: 4 additions & 0 deletions src/hyperspell/resources/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def add_reaction(
"web_crawler",
"trace",
"microsoft_teams",
"gmail_actions",
],
timestamp: str,
connection: Optional[str] | Omit = omit,
Expand Down Expand Up @@ -131,6 +132,7 @@ def send_message(
"web_crawler",
"trace",
"microsoft_teams",
"gmail_actions",
],
text: str,
channel: Optional[str] | Omit = omit,
Expand Down Expand Up @@ -223,6 +225,7 @@ async def add_reaction(
"web_crawler",
"trace",
"microsoft_teams",
"gmail_actions",
],
timestamp: str,
connection: Optional[str] | Omit = omit,
Expand Down Expand Up @@ -290,6 +293,7 @@ async def send_message(
"web_crawler",
"trace",
"microsoft_teams",
"gmail_actions",
],
text: str,
channel: Optional[str] | Omit = omit,
Expand Down
5 changes: 3 additions & 2 deletions src/hyperspell/resources/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import httpx

from .._types import Body, Query, Headers, NotGiven, not_given
from .._utils import path_template
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
Expand Down Expand Up @@ -86,7 +87,7 @@ def revoke(
if not connection_id:
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
return self._delete(
f"/connections/{connection_id}/revoke",
path_template("/connections/{connection_id}/revoke", connection_id=connection_id),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
Expand Down Expand Up @@ -160,7 +161,7 @@ async def revoke(
if not connection_id:
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
return await self._delete(
f"/connections/{connection_id}/revoke",
path_template("/connections/{connection_id}/revoke", connection_id=connection_id),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
Expand Down
Loading
Loading