From f77d9d138e436b71bc705a22d0b88340ff5e0976 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:03:10 +0000 Subject: [PATCH 1/2] fix: improve SSE line-ending normalization and incremental decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .fern/metadata.json | 16 +++--- .fernignore | 3 +- changelog.md | 13 +++++ poetry.lock | 8 +-- pyproject.toml | 4 +- src/newscatcher/core/client_wrapper.py | 4 +- src/newscatcher/core/http_sse/_api.py | 78 +++++++++++++++++++------- 7 files changed, 88 insertions(+), 38 deletions(-) create mode 100644 changelog.md diff --git a/.fern/metadata.json b/.fern/metadata.json index afb5b93..787f2e0 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -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", @@ -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" } \ No newline at end of file diff --git a/.fernignore b/.fernignore index 4b8e914..7515e17 100644 --- a/.fernignore +++ b/.fernignore @@ -15,4 +15,5 @@ test-mock-mode.sh mypy.ini # CI/CD -.github/workflows/ci.yml \ No newline at end of file +.github/workflows/ci.yml +changelog.md diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..6551497 --- /dev/null +++ b/changelog.md @@ -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 + diff --git a/poetry.lock b/poetry.lock index 2a97347..75a1b22 100644 --- a/poetry.lock +++ b/poetry.lock @@ -743,13 +743,13 @@ files = [ [[package]] name = "packaging" -version = "26.1" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"}, - {file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] @@ -1438,4 +1438,4 @@ aiohttp = ["aiohttp", "httpx-aiohttp"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "02ee31852ff3143d28cf513ee3fa1b514fe2ba936dcffc8ab6cc98f7c9570c01" +content-hash = "d007b2e8258e3a9475b9bc4e94cb13783c81486e624f0529b033cf734e528227" diff --git a/pyproject.toml b/pyproject.toml index c9c8dca..f64db3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "newscatcher-sdk" -version = "2.1.0" +version = "2.1.1" description = "" readme = "README.md" authors = [] @@ -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] diff --git a/src/newscatcher/core/client_wrapper.py b/src/newscatcher/core/client_wrapper.py index 19baf46..106d0aa 100644 --- a/src/newscatcher/core/client_wrapper.py +++ b/src/newscatcher/core/client_wrapper.py @@ -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 diff --git a/src/newscatcher/core/http_sse/_api.py b/src/newscatcher/core/http_sse/_api.py index f900b3b..b375399 100644 --- a/src/newscatcher/core/http_sse/_api.py +++ b/src/newscatcher/core/http_sse/_api.py @@ -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 @@ -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 From 1662b5b5d0c6bff7241359270825c83bf649c151 Mon Sep 17 00:00:00 2001 From: oscatcher Date: Fri, 1 May 2026 17:26:08 +0200 Subject: [PATCH 2/2] feat(ci): add complete sdk release workflow --- .fernignore | 1 + .github/workflows/sdk-release.yml | 129 ++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 .github/workflows/sdk-release.yml diff --git a/.fernignore b/.fernignore index 7515e17..1d01ce5 100644 --- a/.fernignore +++ b/.fernignore @@ -16,4 +16,5 @@ mypy.ini # CI/CD .github/workflows/ci.yml +.github/workflows/sdk-release.yml changelog.md diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml new file mode 100644 index 0000000..5d823ef --- /dev/null +++ b/.github/workflows/sdk-release.yml @@ -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 \ No newline at end of file