Skip to content

Commit c9cff94

Browse files
release: 0.15.0 (#21)
* codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * feat(api): api update * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * fix: sanitize endpoint path params * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * feat(api): api update * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * chore(internal): update gitignore * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * chore(ci): skip lint on metadata-only changes Note that we still want to run tests, as these depend on the metadata. * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * release: 0.15.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com>
1 parent 0c0c3fe commit c9cff94

File tree

20 files changed

+320
-71
lines changed

20 files changed

+320
-71
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
timeout-minutes: 10
2020
name: lint
2121
runs-on: ${{ github.repository == 'stainless-sdks/sent-dm-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
22-
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
22+
if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
2323
steps:
2424
- uses: actions/checkout@v6
2525

@@ -35,7 +35,7 @@ jobs:
3535
run: ./scripts/lint
3636

3737
build:
38-
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
38+
if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
3939
timeout-minutes: 10
4040
name: build
4141
permissions:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.prism.log
2+
.stdy.log
23
_dev
34

45
__pycache__

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "0.14.1"
2+
".": "0.15.0"
33
}

.stats.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
configured_endpoints: 40
2-
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sent%2Fsent-dm-2d0bb64dc84ba67ee91db6ff81424a968c5ddea4d2844ba67fc9b4b27881d60f.yml
3-
openapi_spec_hash: 8e1d6bc2a6c6afef625e2bdcdf28ac63
2+
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sent%2Fsent-dm-aa5788f722a5500abb87ae7d7dd559e5802d3f45abbc525d105ca1010f553070.yml
3+
openapi_spec_hash: e122ccb2f2f3062194364f20fd58fee1
44
config_hash: d8e8429147c4e214ff53c11e7ab2a1a6

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## 0.15.0 (2026-03-25)
4+
5+
Full Changelog: [v0.14.1...v0.15.0](https://github.com/sentdm/sent-dm-python/compare/v0.14.1...v0.15.0)
6+
7+
### Features
8+
9+
* **api:** api update ([1b808cf](https://github.com/sentdm/sent-dm-python/commit/1b808cf0f33936836ad7588857056d95fc18bd79))
10+
* **api:** api update ([93500f0](https://github.com/sentdm/sent-dm-python/commit/93500f067df17cbb5ca0384b4d5a2600f699dfda))
11+
12+
13+
### Bug Fixes
14+
15+
* sanitize endpoint path params ([7931fda](https://github.com/sentdm/sent-dm-python/commit/7931fda88ab2bfb29cf3fd9f0622e2304bccf662))
16+
17+
18+
### Chores
19+
20+
* **ci:** skip lint on metadata-only changes ([e6f221b](https://github.com/sentdm/sent-dm-python/commit/e6f221bd0fbe0a23d661db21eb54356bbb5b2a9e))
21+
* **internal:** update gitignore ([0697f78](https://github.com/sentdm/sent-dm-python/commit/0697f7822f07efe9479e262a4c8e5281f07ff374))
22+
323
## 0.14.1 (2026-03-17)
424

525
Full Changelog: [v0.14.0...v0.14.1](https://github.com/sentdm/sent-dm-python/compare/v0.14.0...v0.14.1)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sentdm"
3-
version = "0.14.1"
3+
version = "0.15.0"
44
description = "The official Python library for the sent-dm API"
55
dynamic = ["readme"]
66
license = "Apache-2.0"

src/sent_dm/_utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ._path import path_template as path_template
12
from ._sync import asyncify as asyncify
23
from ._proxy import LazyProxy as LazyProxy
34
from ._utils import (

src/sent_dm/_utils/_path.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from typing import (
5+
Any,
6+
Mapping,
7+
Callable,
8+
)
9+
from urllib.parse import quote
10+
11+
# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E).
12+
_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$")
13+
14+
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
15+
16+
17+
def _quote_path_segment_part(value: str) -> str:
18+
"""Percent-encode `value` for use in a URI path segment.
19+
20+
Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe.
21+
https://datatracker.ietf.org/doc/html/rfc3986#section-3.3
22+
"""
23+
# quote() already treats unreserved characters (letters, digits, and -._~)
24+
# as safe, so we only need to add sub-delims, ':', and '@'.
25+
# Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted.
26+
return quote(value, safe="!$&'()*+,;=:@")
27+
28+
29+
def _quote_query_part(value: str) -> str:
30+
"""Percent-encode `value` for use in a URI query string.
31+
32+
Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe.
33+
https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
34+
"""
35+
return quote(value, safe="!$'()*+,;:@/?")
36+
37+
38+
def _quote_fragment_part(value: str) -> str:
39+
"""Percent-encode `value` for use in a URI fragment.
40+
41+
Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe.
42+
https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
43+
"""
44+
return quote(value, safe="!$&'()*+,;=:@/?")
45+
46+
47+
def _interpolate(
48+
template: str,
49+
values: Mapping[str, Any],
50+
quoter: Callable[[str], str],
51+
) -> str:
52+
"""Replace {name} placeholders in `template`, quoting each value with `quoter`.
53+
54+
Placeholder names are looked up in `values`.
55+
56+
Raises:
57+
KeyError: If a placeholder is not found in `values`.
58+
"""
59+
# re.split with a capturing group returns alternating
60+
# [text, name, text, name, ..., text] elements.
61+
parts = _PLACEHOLDER_RE.split(template)
62+
63+
for i in range(1, len(parts), 2):
64+
name = parts[i]
65+
if name not in values:
66+
raise KeyError(f"a value for placeholder {{{name}}} was not provided")
67+
val = values[name]
68+
if val is None:
69+
parts[i] = "null"
70+
elif isinstance(val, bool):
71+
parts[i] = "true" if val else "false"
72+
else:
73+
parts[i] = quoter(str(values[name]))
74+
75+
return "".join(parts)
76+
77+
78+
def path_template(template: str, /, **kwargs: Any) -> str:
79+
"""Interpolate {name} placeholders in `template` from keyword arguments.
80+
81+
Args:
82+
template: The template string containing {name} placeholders.
83+
**kwargs: Keyword arguments to interpolate into the template.
84+
85+
Returns:
86+
The template with placeholders interpolated and percent-encoded.
87+
88+
Safe characters for percent-encoding are dependent on the URI component.
89+
Placeholders in path and fragment portions are percent-encoded where the `segment`
90+
and `fragment` sets from RFC 3986 respectively are considered safe.
91+
Placeholders in the query portion are percent-encoded where the `query` set from
92+
RFC 3986 §3.3 is considered safe except for = and & characters.
93+
94+
Raises:
95+
KeyError: If a placeholder is not found in `kwargs`.
96+
ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments).
97+
"""
98+
# Split the template into path, query, and fragment portions.
99+
fragment_template: str | None = None
100+
query_template: str | None = None
101+
102+
rest = template
103+
if "#" in rest:
104+
rest, fragment_template = rest.split("#", 1)
105+
if "?" in rest:
106+
rest, query_template = rest.split("?", 1)
107+
path_template = rest
108+
109+
# Interpolate each portion with the appropriate quoting rules.
110+
path_result = _interpolate(path_template, kwargs, _quote_path_segment_part)
111+
112+
# Reject dot-segments (. and ..) in the final assembled path. The check
113+
# runs after interpolation so that adjacent placeholders or a mix of static
114+
# text and placeholders that together form a dot-segment are caught.
115+
# Also reject percent-encoded dot-segments to protect against incorrectly
116+
# implemented normalization in servers/proxies.
117+
for segment in path_result.split("/"):
118+
if _DOT_SEGMENT_RE.match(segment):
119+
raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed")
120+
121+
result = path_result
122+
if query_template is not None:
123+
result += "?" + _interpolate(query_template, kwargs, _quote_query_part)
124+
if fragment_template is not None:
125+
result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part)
126+
127+
return result

src/sent_dm/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

33
__title__ = "sent_dm"
4-
__version__ = "0.14.1" # x-release-please-version
4+
__version__ = "0.15.0" # x-release-please-version

src/sent_dm/resources/contacts.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from ..types import contact_list_params, contact_create_params, contact_delete_params, contact_update_params
1010
from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given
11-
from .._utils import maybe_transform, strip_not_given, async_maybe_transform
11+
from .._utils import path_template, maybe_transform, strip_not_given, async_maybe_transform
1212
from .._compat import cached_property
1313
from .._resource import SyncAPIResource, AsyncAPIResource
1414
from .._response import (
@@ -133,7 +133,7 @@ def retrieve(
133133
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
134134
extra_headers = {**strip_not_given({"x-profile-id": x_profile_id}), **(extra_headers or {})}
135135
return self._get(
136-
f"/v3/contacts/{id}",
136+
path_template("/v3/contacts/{id}", id=id),
137137
options=make_request_options(
138138
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
139139
),
@@ -189,7 +189,7 @@ def update(
189189
**(extra_headers or {}),
190190
}
191191
return self._patch(
192-
f"/v3/contacts/{id}",
192+
path_template("/v3/contacts/{id}", id=id),
193193
body=maybe_transform(
194194
{
195195
"default_channel": default_channel,
@@ -300,7 +300,7 @@ def delete(
300300
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
301301
extra_headers = {**strip_not_given({"x-profile-id": x_profile_id}), **(extra_headers or {})}
302302
return self._delete(
303-
f"/v3/contacts/{id}",
303+
path_template("/v3/contacts/{id}", id=id),
304304
body=maybe_transform(body, contact_delete_params.ContactDeleteParams),
305305
options=make_request_options(
306306
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -418,7 +418,7 @@ async def retrieve(
418418
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
419419
extra_headers = {**strip_not_given({"x-profile-id": x_profile_id}), **(extra_headers or {})}
420420
return await self._get(
421-
f"/v3/contacts/{id}",
421+
path_template("/v3/contacts/{id}", id=id),
422422
options=make_request_options(
423423
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
424424
),
@@ -474,7 +474,7 @@ async def update(
474474
**(extra_headers or {}),
475475
}
476476
return await self._patch(
477-
f"/v3/contacts/{id}",
477+
path_template("/v3/contacts/{id}", id=id),
478478
body=await async_maybe_transform(
479479
{
480480
"default_channel": default_channel,
@@ -585,7 +585,7 @@ async def delete(
585585
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
586586
extra_headers = {**strip_not_given({"x-profile-id": x_profile_id}), **(extra_headers or {})}
587587
return await self._delete(
588-
f"/v3/contacts/{id}",
588+
path_template("/v3/contacts/{id}", id=id),
589589
body=await async_maybe_transform(body, contact_delete_params.ContactDeleteParams),
590590
options=make_request_options(
591591
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout

0 commit comments

Comments
 (0)