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
16 changes: 8 additions & 8 deletions .fern/metadata.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"cliVersion": "4.90.0",
"cliVersion": "5.1.0",
"generatorName": "fernapi/fern-python-sdk",
"generatorVersion": "5.5.11",
"generatorVersion": "5.8.4",
"generatorConfig": {
"client": {
"class_name": "BaseNewscatcherApi",
Expand All @@ -10,10 +10,10 @@
"exported_filename": "client.py"
}
},
"originGitCommit": "3707beee5d0e236f2469e89f7b19c3a08efb6f95",
"originGitCommitIsDirty": false,
"invokedBy": "manual",
"requestedVersion": "2.1.0",
"ciProvider": null,
"sdkVersion": "2.1.0"
"originGitCommit": "57be6b5662276253461696b49ad9ff6448cc29f3",
"originGitCommitIsDirty": true,
"invokedBy": "ci",
"requestedVersion": "AUTO",
"ciProvider": "unknown",
"sdkVersion": "2.1.1"
}
4 changes: 3 additions & 1 deletion .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ test-mock-mode.sh
mypy.ini

# CI/CD
.github/workflows/ci.yml
.github/workflows/ci.yml
.github/workflows/sdk-release.yml
changelog.md
129 changes: 129 additions & 0 deletions .github/workflows/sdk-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: SDK Release

on:
workflow_dispatch:
pull_request:
types: [closed]
branches:
- main

jobs:
compile:
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Bootstrap poetry
run: |
curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1
- name: Install dependencies
run: poetry install
- name: Compile
run: poetry run mypy .

test:
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Bootstrap poetry
run: |
curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1
- name: Install dependencies
run: poetry install
- name: Test
run: poetry run pytest -rP .

release:
needs: [compile, test]
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0

- name: Read version from pyproject.toml
id: version
run: |
VERSION=$(grep -m1 '^version = ' pyproject.toml | grep -oP '"\K[^"]+')
if [ -z "$VERSION" ]; then
echo "ERROR: Could not read version from pyproject.toml"
exit 1
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG=v$VERSION" >> $GITHUB_OUTPUT

- name: Get latest git tag
id: latest_tag
run: |
LATEST=$(git describe --tags --abbrev=0 2>/dev/null || echo "none")
echo "LATEST_TAG=$LATEST" >> $GITHUB_OUTPUT

- name: Check if version was bumped
id: check
run: |
TAG="${{ steps.version.outputs.TAG }}"
LATEST="${{ steps.latest_tag.outputs.LATEST_TAG }}"
if [ "$TAG" = "$LATEST" ]; then
echo "bumped=false" >> $GITHUB_OUTPUT
echo "Version $TAG already tagged — skipping release."
else
echo "bumped=true" >> $GITHUB_OUTPUT
echo "New version detected: $TAG (previous: $LATEST)"
fi

- name: Set up python
if: steps.check.outputs.bumped == 'true'
uses: actions/setup-python@v4
with:
python-version: "3.10"

- name: Bootstrap poetry
if: steps.check.outputs.bumped == 'true'
run: |
curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1

- name: Install dependencies
if: steps.check.outputs.bumped == 'true'
run: poetry install

- name: Create and push tag
if: steps.check.outputs.bumped == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "${{ steps.version.outputs.TAG }}"
git push origin "${{ steps.version.outputs.TAG }}"

- name: Publish to PyPI
if: steps.check.outputs.bumped == 'true'
run: |
poetry config pypi-token.pypi "$PYPI_TOKEN"
poetry --no-interaction -v publish --build
env:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}

- name: Create GitHub release with generated notes
if: steps.check.outputs.bumped == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.version.outputs.TAG }}" \
--title "${{ steps.version.outputs.TAG }}" \
--generate-notes \
--latest
13 changes: 13 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## 2.1.1 - 2026-04-30
* fix: improve SSE line-ending normalization and incremental decoding
* Refactor the SSE event source to use Python's incremental codec decoder
* for correct multi-byte character handling across chunk boundaries, and
* add proper normalization of CR, LF, and CRLF line endings per the SSE
* specification. Also narrows the urllib3 dependency to >=2.6.3.
* Key changes:
* Add `_normalize_sse_line_endings()` to handle \r\n, bare \r, and \n uniformly
* Replace one-shot chunk decoding with `codecs.getincrementaldecoder` in both `iter_sse` and `aiter_sse`
* Flush incremental decoder at end of stream to avoid dropped trailing bytes
* Narrow urllib3 version constraint from `>=1.26.19,<2.0.0 || >=2.2.2,<3.0.0` to `>=2.6.3,<3.0.0`
* 🌿 Generated with Fern

8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ dynamic = ["version"]

[tool.poetry]
name = "newscatcher-sdk"
version = "2.1.0"
version = "2.1.1"
description = ""
readme = "README.md"
authors = []
Expand Down Expand Up @@ -52,7 +52,7 @@ pytest-asyncio = "^1.0.0"
pytest-xdist = "^3.6.1"
python-dateutil = "^2.9.0"
types-python-dateutil = "^2.9.0.20240316"
urllib3 = ">=1.26.19,<2.0.0 || >=2.2.2,<3.0.0"
urllib3 = ">=2.6.3,<3.0.0"
ruff = "==0.11.5"

[tool.pytest.ini_options]
Expand Down
4 changes: 2 additions & 2 deletions src/newscatcher/core/client_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ def get_headers(self) -> typing.Dict[str, str]:
import platform

headers: typing.Dict[str, str] = {
"User-Agent": "newscatcher-sdk/2.1.0",
"User-Agent": "newscatcher-sdk/2.1.1",
"X-Fern-Language": "Python",
"X-Fern-Runtime": f"python/{platform.python_version()}",
"X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}",
"X-Fern-SDK-Name": "newscatcher-sdk",
"X-Fern-SDK-Version": "2.1.0",
"X-Fern-SDK-Version": "2.1.1",
**(self.get_custom_headers() or {}),
}
headers["x-api-token"] = self.api_key
Expand Down
78 changes: 57 additions & 21 deletions src/newscatcher/core/http_sse/_api.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# This file was auto-generated by Fern from our API Definition.

import codecs
import re
from contextlib import asynccontextmanager, contextmanager
from typing import Any, AsyncGenerator, AsyncIterator, Iterator, cast
from typing import Any, AsyncGenerator, AsyncIterator, Iterator

import httpx
from ._decoders import SSEDecoder
Expand Down Expand Up @@ -45,46 +46,81 @@ def _get_charset(self) -> str:
def response(self) -> httpx.Response:
return self._response

@staticmethod
def _normalize_sse_line_endings(buf: str) -> str:
"""Normalize line endings per the SSE spec (\\r\\n → \\n, bare \\r → \\n).

A trailing \\r is preserved because it may pair with a leading \\n in
the next chunk to form a single \\r\\n terminator.
"""
buf = buf.replace("\r\n", "\n")
if buf.endswith("\r"):
return buf[:-1].replace("\r", "\n") + "\r"
return buf.replace("\r", "\n")

def iter_sse(self) -> Iterator[ServerSentEvent]:
self._check_content_type()
decoder = SSEDecoder()
charset = self._get_charset()
text_decoder = codecs.getincrementaldecoder(charset)(errors="replace")

buffer = ""
buf = ""
for chunk in self._response.iter_bytes():
# Decode chunk using detected charset
text_chunk = chunk.decode(charset, errors="replace")
buffer += text_chunk

# Process complete lines
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.rstrip("\r")
buf += text_decoder.decode(chunk)
buf = self._normalize_sse_line_endings(buf)

while "\n" in buf:
line, buf = buf.split("\n", 1)
sse = decoder.decode(line)
# when we reach a "\n\n" => line = ''
# => decoder will attempt to return an SSE Event
if sse is not None:
yield sse

# Process any remaining data in buffer
if buffer.strip():
line = buffer.rstrip("\r")
# Flush any remaining bytes from the incremental decoder
buf += text_decoder.decode(b"", final=True)
buf = buf.replace("\r\n", "\n").replace("\r", "\n")

while "\n" in buf:
line, buf = buf.split("\n", 1)
sse = decoder.decode(line)
if sse is not None:
yield sse

if buf.strip():
sse = decoder.decode(buf)
if sse is not None:
yield sse

async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]:
self._check_content_type()
decoder = SSEDecoder()
lines = cast(AsyncGenerator[str, None], self._response.aiter_lines())
try:
async for line in lines:
line = line.rstrip("\n")
charset = self._get_charset()
text_decoder = codecs.getincrementaldecoder(charset)(errors="replace")

buf = ""
async for chunk in self._response.aiter_bytes():
buf += text_decoder.decode(chunk)
buf = self._normalize_sse_line_endings(buf)

while "\n" in buf:
line, buf = buf.split("\n", 1)
sse = decoder.decode(line)
if sse is not None:
yield sse
finally:
await lines.aclose()

# Flush any remaining bytes from the incremental decoder
buf += text_decoder.decode(b"", final=True)
buf = buf.replace("\r\n", "\n").replace("\r", "\n")

while "\n" in buf:
line, buf = buf.split("\n", 1)
sse = decoder.decode(line)
if sse is not None:
yield sse

if buf.strip():
sse = decoder.decode(buf)
if sse is not None:
yield sse


@contextmanager
Expand Down
Loading