Repository: kubernetes-client/python
Pull request #2172 (2023-12-28 17:58:15)

Automated release of 29.0.0b1 upstream release 29.0 1703785760
<!--  Thanks for sending a pull request!  Here are some tips for you:

1. If this is your first time, please read our contributor guidelines: https://git.k8s.io/community/contributors/guide/first-contribution.md#your-first-contribution and developer guide https://git.k8s.io/community/contributors/devel/development.md#development-guide
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. For reference on required PR/issue labels, read here:
https://git.k8s.io/community/contributors/devel/sig-release/release.md#issuepr-kind-label
3. Ensure you have added or ran the appropriate tests for your PR: https://git.k8s.io/community/contributors/devel/sig-testing/testing.md
4. If you want *faster* PR reviews, read how: https://git.k8s.io/community/contributors/guide/pull-requests.md#best-practices-for-faster-reviews
5. If the PR is unfinished, see how to mark it: https://git.k8s.io/community/contributors/guide/pull-requests.md#marking-unfinished-pull-requests
-->

#### What type of PR is this?
/kind bug

<!--
Add one of the following kinds:
/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind design

Optionally add one or more of the following kinds if applicable:
/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:

#### Which issue(s) this PR fixes:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
-->
Fixes #

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?
<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

For more information on release notes see: https://git.k8s.io/community/contributors/guide/release-notes.md
-->
```release-note
NONE
```

#### Additional documentation e.g., KEPs (Kubernetes Enhancement Proposals), usage docs, etc.:

<!--
This section can be blank if this pull request does not require a release note.

When adding links which point to resources within git repositories, like
KEPs or supporting documentation, please reference a specific commit and avoid
linking directly to the master branch. This ensures that links reference a
specific point in time, rather than a document that may change over time.

See here for guidance on getting permanent links to files: https://help.github.com/en/articles/getting-permanent-links-to-files

Please use the following format for linking documentation:
- [KEP]: <link>
- [Usage]: <link>
- [Other doc]: <link>
-->
```docs

```



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 006f694b5..9e7072158 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+# v29.0.0b1
+
+Kubernetes API Version: v1.29.0
+
+### Bug or Regression
+- Fix UTF-8 failures in Watch (#2100, @davidopic)
+- Fix upper version boundary of urllib3, since other dependencies don't support urllib3 in version 2 (#2105, @jsaalfeld)
+
 # v29.0.0a1
 
 Kubernetes API Version: v1.29.0
diff --git a/kubernetes/README.md b/kubernetes/README.md
index 7f745b76d..a109628c9 100644
--- a/kubernetes/README.md
+++ b/kubernetes/README.md
@@ -4,7 +4,7 @@ No description provided (generated by Openapi Generator https://github.com/opena
 This Python package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
 
 - API version: release-1.29
-- Package version: 29.0.0a1
+- Package version: 29.0.0b1
 - Build package: org.openapitools.codegen.languages.PythonClientCodegen
 
 ## Requirements.
diff --git a/kubernetes/__init__.py b/kubernetes/__init__.py
index 42a5396de..b0661c890 100644
--- a/kubernetes/__init__.py
+++ b/kubernetes/__init__.py
@@ -14,7 +14,7 @@
 
 __project__ = 'kubernetes'
 # The version is auto-updated. Please do not edit.
-__version__ = "29.0.0a1"
+__version__ = "29.0.0b1"
 
 from . import client
 from . import config
diff --git a/kubernetes/base/watch/watch.py b/kubernetes/base/watch/watch.py
index 71fd45919..f78ab11d6 100644
--- a/kubernetes/base/watch/watch.py
+++ b/kubernetes/base/watch/watch.py
@@ -52,20 +52,33 @@ def _find_return_type(func):
 
 
 def iter_resp_lines(resp):
-    prev = ""
-    for seg in resp.stream(amt=None, decode_content=False):
-        if isinstance(seg, bytes):
-            seg = seg.decode('utf8')
-        seg = prev + seg
-        lines = seg.split("\n")
-        if not seg.endswith("\n"):
-            prev = lines[-1]
-            lines = lines[:-1]
+    buffer = bytearray()
+    for segment in resp.stream(amt=None, decode_content=False):
+
+        # Append the segment (chunk) to the buffer
+        #
+        # Performance note: depending on contents of buffer and the type+value of segment,
+        # encoding segment into the buffer could be a wasteful step. The approach used here
+        # simplifies the logic farther down, but in the future it may be reasonable to
+        # sacrifice readability for performance.
+        if isinstance(segment, bytes):
+            buffer.extend(segment)
+        elif isinstance(segment, str):
+            buffer.extend(segment.encode("utf-8"))
         else:
-            prev = ""
-        for line in lines:
+            raise TypeError(
+                f"Received invalid segment type, {type(segment)}, from stream. Accepts only 'str' or 'bytes'.")
+
+        # Split by newline (safe for utf-8 because multi-byte sequences cannot contain the newline byte)
+        next_newline = buffer.find(b'\n')
+        while next_newline != -1:
+            # Convert bytes to a valid utf-8 string, replacing any invalid utf-8 with the '�' character
+            line = buffer[:next_newline].decode(
+                "utf-8", errors="replace")
+            buffer = buffer[next_newline+1:]
             if line:
                 yield line
+            next_newline = buffer.find(b'\n')
 
 
 class Watch(object):
diff --git a/kubernetes/client/__init__.py b/kubernetes/client/__init__.py
index 93d933814..78572b977 100644
--- a/kubernetes/client/__init__.py
+++ b/kubernetes/client/__init__.py
@@ -14,7 +14,7 @@
 
 from __future__ import absolute_import
 
-__version__ = "29.0.0a1"
+__version__ = "29.0.0b1"
 
 # import apis into sdk package
 from kubernetes.client.api.well_known_api import WellKnownApi
diff --git a/kubernetes/client/api_client.py b/kubernetes/client/api_client.py
index 2d9e2978d..2385393dc 100644
--- a/kubernetes/client/api_client.py
+++ b/kubernetes/client/api_client.py
@@ -78,7 +78,7 @@ class ApiClient(object):
             self.default_headers[header_name] = header_value
         self.cookie = cookie
         # Set default User-Agent.
-        self.user_agent = 'OpenAPI-Generator/29.0.0a1/python'
+        self.user_agent = 'OpenAPI-Generator/29.0.0b1/python'
         self.client_side_validation = configuration.client_side_validation
 
     def __enter__(self):
diff --git a/kubernetes/client/configuration.py b/kubernetes/client/configuration.py
index 973a93d73..555a92b04 100644
--- a/kubernetes/client/configuration.py
+++ b/kubernetes/client/configuration.py
@@ -354,7 +354,7 @@ class Configuration(object):
                "OS: {env}\n"\
                "Python Version: {pyversion}\n"\
                "Version of the API: release-1.29\n"\
-               "SDK Package Version: 29.0.0a1".\
+               "SDK Package Version: 29.0.0b1".\
                format(env=sys.platform, pyversion=sys.version)
 
     def get_host_settings(self):
diff --git a/scripts/constants.py b/scripts/constants.py
index 6f431ceae..91668c369 100644
--- a/scripts/constants.py
+++ b/scripts/constants.py
@@ -18,13 +18,13 @@ import sys
 KUBERNETES_BRANCH = "release-1.29"
 
 # client version for packaging and releasing.
-CLIENT_VERSION = "29.0.0a1"
+CLIENT_VERSION = "29.0.0b1"
 
 # Name of the release package
 PACKAGE_NAME = "kubernetes"
 
 # Stage of development, mainly used in setup.py's classifiers.
-DEVELOPMENT_STATUS = "3 - Alpha"
+DEVELOPMENT_STATUS = "4 - Beta"
 
 
 # If called directly, return the constant value given
diff --git a/setup.py b/setup.py
index 9441b5f2f..0de7ac73c 100644
--- a/setup.py
+++ b/setup.py
@@ -16,9 +16,9 @@ from setuptools import setup
 
 # Do not edit these constants. They will be updated automatically
 # by scripts/update-client.sh.
-CLIENT_VERSION = "29.0.0a1"
+CLIENT_VERSION = "29.0.0b1"
 PACKAGE_NAME = "kubernetes"
-DEVELOPMENT_STATUS = "3 - Alpha"
+DEVELOPMENT_STATUS = "4 - Beta"
 
 # To install the library, run the following
 #


Test patch:
diff --git a/.github/workflows/e2e-master.yaml b/.github/workflows/e2e-master.yaml
index e40e4e542..28aa84df0 100644
--- a/.github/workflows/e2e-master.yaml
+++ b/.github/workflows/e2e-master.yaml
@@ -30,7 +30,7 @@ jobs:
         # as we sync with Kubernetes upstream
         config: .github/workflows/kind-configs/cluster-1.25.yaml
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/.github/workflows/e2e-release-11.0.yaml b/.github/workflows/e2e-release-11.0.yaml
index 0166031ca..de7af5ff5 100644
--- a/.github/workflows/e2e-release-11.0.yaml
+++ b/.github/workflows/e2e-release-11.0.yaml
@@ -30,7 +30,7 @@ jobs:
         # as we sync with Kubernetes upstream
         config: .github/workflows/kind-configs/cluster-1.15.yaml
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/.github/workflows/e2e-release-12.0.yaml b/.github/workflows/e2e-release-12.0.yaml
index 2feb83b29..07b111f76 100644
--- a/.github/workflows/e2e-release-12.0.yaml
+++ b/.github/workflows/e2e-release-12.0.yaml
@@ -30,7 +30,7 @@ jobs:
         # as we sync with Kubernetes upstream
         config: .github/workflows/kind-configs/cluster-1.16.yaml
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/.github/workflows/e2e-release-17.0.yaml b/.github/workflows/e2e-release-17.0.yaml
index 0939399fb..16bf75120 100644
--- a/.github/workflows/e2e-release-17.0.yaml
+++ b/.github/workflows/e2e-release-17.0.yaml
@@ -30,7 +30,7 @@ jobs:
         # as we sync with Kubernetes upstream
         config: .github/workflows/kind-configs/cluster-1.17.yaml
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/.github/workflows/e2e-release-18.0.yaml b/.github/workflows/e2e-release-18.0.yaml
index 5297063d9..5e4d97654 100644
--- a/.github/workflows/e2e-release-18.0.yaml
+++ b/.github/workflows/e2e-release-18.0.yaml
@@ -30,7 +30,7 @@ jobs:
         # as we sync with Kubernetes upstream
         config: .github/workflows/kind-configs/cluster-1.18.yaml
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/.github/workflows/e2e-release-26.0.yaml b/.github/workflows/e2e-release-26.0.yaml
index 517d67590..87c3043c3 100644
--- a/.github/workflows/e2e-release-26.0.yaml
+++ b/.github/workflows/e2e-release-26.0.yaml
@@ -30,7 +30,7 @@ jobs:
         # as we sync with Kubernetes upstream
         config: .github/workflows/kind-configs/cluster-1.26.yaml
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index ab6e49932..04fe2f2d3 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -17,7 +17,7 @@ jobs:
       with:
         submodules: true
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/kubernetes/base/watch/watch_test.py b/kubernetes/base/watch/watch_test.py
index 8164e7b5d..c5bc5c378 100644
--- a/kubernetes/base/watch/watch_test.py
+++ b/kubernetes/base/watch/watch_test.py
@@ -61,6 +61,9 @@ class WatchTests(unittest.TestCase):
             if count == 4:
                 w.stop()
 
+        # make sure that all three records were consumed by the stream
+        self.assertEqual(4, count)
+
         fake_api.get_namespaces.assert_called_once_with(
             _preload_content=False, watch=True)
         fake_resp.stream.assert_called_once_with(
@@ -68,6 +71,123 @@ class WatchTests(unittest.TestCase):
         fake_resp.close.assert_called_once()
         fake_resp.release_conn.assert_called_once()
 
+    def test_watch_with_interspersed_newlines(self):
+        fake_resp = Mock()
+        fake_resp.close = Mock()
+        fake_resp.release_conn = Mock()
+        fake_resp.stream = Mock(
+            return_value=[
+                '\n',
+                '{"type": "ADDED", "object": {"metadata":',
+                '{"name": "test1","resourceVersion": "1"}}}\n{"type": "ADDED", ',
+                '"object": {"metadata": {"name": "test2", "resourceVersion": "2"}}}\n',
+                '\n',
+                '',
+                '{"type": "ADDED", "object": {"metadata": {"name": "test3", "resourceVersion": "3"}}}\n',
+                '\n\n\n',
+                '\n',
+            ])
+
+        fake_api = Mock()
+        fake_api.get_namespaces = Mock(return_value=fake_resp)
+        fake_api.get_namespaces.__doc__ = ':return: V1NamespaceList'
+
+        w = Watch()
+        count = 0
+
+        # Consume all test events from the mock service, stopping when no more data is available.
+        # Note that "timeout_seconds" below is not a timeout; rather, it disables retries and is
+        # the only way to do so. Without that, the stream will re-read the test data forever.
+        for e in w.stream(fake_api.get_namespaces, timeout_seconds=1):
+            count += 1
+            self.assertEqual("test%d" % count, e['object'].metadata.name)
+        self.assertEqual(3, count)
+
+    def test_watch_with_multibyte_utf8(self):
+        fake_resp = Mock()
+        fake_resp.close = Mock()
+        fake_resp.release_conn = Mock()
+        fake_resp.stream = Mock(
+            return_value=[
+                # two-byte utf-8 character
+                '{"type":"MODIFIED","object":{"data":{"utf-8":"© 1"},"metadata":{"name":"test1","resourceVersion":"1"}}}\n',
+                # same copyright character expressed as bytes
+                b'{"type":"MODIFIED","object":{"data":{"utf-8":"\xC2\xA9 2"},"metadata":{"name":"test2","resourceVersion":"2"}}}\n'
+                # same copyright character with bytes split across two stream chunks
+                b'{"type":"MODIFIED","object":{"data":{"utf-8":"\xC2',
+                b'\xA9 3"},"metadata":{"n',
+                # more chunks of the same event, sent as a mix of bytes and strings
+                'ame":"test3","resourceVersion":"3"',
+                '}}}',
+                b'\n'
+            ])
+
+        fake_api = Mock()
+        fake_api.get_configmaps = Mock(return_value=fake_resp)
+        fake_api.get_configmaps.__doc__ = ':return: V1ConfigMapList'
+
+        w = Watch()
+        count = 0
+
+        # Consume all test events from the mock service, stopping when no more data is available.
+        # Note that "timeout_seconds" below is not a timeout; rather, it disables retries and is
+        # the only way to do so. Without that, the stream will re-read the test data forever.
+        for event in w.stream(fake_api.get_configmaps, timeout_seconds=1):
+            count += 1
+            self.assertEqual("MODIFIED", event['type'])
+            self.assertEqual("test%d" % count, event['object'].metadata.name)
+            self.assertEqual("© %d" % count, event['object'].data["utf-8"])
+            self.assertEqual(
+                "%d" % count, event['object'].metadata.resource_version)
+            self.assertEqual("%d" % count, w.resource_version)
+        self.assertEqual(3, count)
+
+    def test_watch_with_invalid_utf8(self):
+        fake_resp = Mock()
+        fake_resp.close = Mock()
+        fake_resp.release_conn = Mock()
+        fake_resp.stream = Mock(
+            # test 1 uses 1 invalid utf-8 byte
+            # test 2 uses a sequence of 2 invalid utf-8 bytes
+            # test 3 uses a sequence of 3 invalid utf-8 bytes
+            return_value=[
+                # utf-8 sequence for 😄 is \xF0\x9F\x98\x84
+                # all other sequences below are invalid
+                # ref: https://www.w3.org/2001/06/utf-8-wrong/UTF-8-test.html
+                b'{"type":"MODIFIED","object":{"data":{"utf-8":"\xF0\x9F\x98\x84 1","invalid":"\x80 1"},"metadata":{"name":"test1"}}}\n',
+                b'{"type":"MODIFIED","object":{"data":{"utf-8":"\xF0\x9F\x98\x84 2","invalid":"\xC0\xAF 2"},"metadata":{"name":"test2"}}}\n',
+                # mix bytes/strings and split byte sequences across chunks
+                b'{"type":"MODIFIED","object":{"data":{"utf-8":"\xF0\x9F\x98',
+                b'\x84 ',
+                b'',
+                b'3","invalid":"\xE0\x80',
+                b'\xAF ',
+                '3"},"metadata":{"n',
+                'ame":"test3"',
+                '}}}',
+                b'\n'
+            ])
+
+        fake_api = Mock()
+        fake_api.get_configmaps = Mock(return_value=fake_resp)
+        fake_api.get_configmaps.__doc__ = ':return: V1ConfigMapList'
+
+        w = Watch()
+        count = 0
+
+        # Consume all test events from the mock service, stopping when no more data is available.
+        # Note that "timeout_seconds" below is not a timeout; rather, it disables retries and is
+        # the only way to do so. Without that, the stream will re-read the test data forever.
+        for event in w.stream(fake_api.get_configmaps, timeout_seconds=1):
+            count += 1
+            self.assertEqual("MODIFIED", event['type'])
+            self.assertEqual("test%d" % count, event['object'].metadata.name)
+            self.assertEqual("😄 %d" % count, event['object'].data["utf-8"])
+            # expect N replacement characters in test N
+            self.assertEqual("� %d".replace('�', '�'*count) %
+                             count, event['object'].data["invalid"])
+        self.assertEqual(3, count)
+
     def test_watch_for_follow(self):
         fake_resp = Mock()
         fake_resp.close = Mock()