From e2d59ee07c2a07be63bf4af60608840b918be791 Mon Sep 17 00:00:00 2001 From: Neng Wan Date: Fri, 14 Aug 2026 14:49:08 +0800 Subject: [PATCH 1/6] fix(model): stop using removed peewee.basestring in Model._validate peewee>=4 dropped the py2-compat `basestring` alias, so `save(only=[...])` raised AttributeError on any environment with peewee 4 installed since Model._validate() checked `isinstance(field, pw.basestring)`. Use the builtin str instead, which is correct for both peewee 3.x and 4.x given peeweext already requires python_requires='>=3'. Adds a regression test that simulates a peewee>=4 environment (no peewee.basestring) without touching peewee's own internals. Co-Authored-By: Claude --- peeweext/model.py | 2 +- tests/test_model.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/peeweext/model.py b/peeweext/model.py index e884db0..dd8e7db 100644 --- a/peeweext/model.py +++ b/peeweext/model.py @@ -124,7 +124,7 @@ def _validate(self, only=None): if only: items = [] for field in only: - if isinstance(field, pw.basestring): + if isinstance(field, str): name = field else: name = field.name diff --git a/tests/test_model.py b/tests/test_model.py index 33eaca2..95cdd5c 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -186,6 +186,38 @@ def test_validator(table): note.save() +class _PeeweeWithoutBasestring: + """Proxies to the real `peewee` module but raises AttributeError for + `basestring`, mimicking peewee>=4 which dropped that py2-compat alias. + Only used to patch the `pw` name inside `peeweext.model`, so peewee's + own internals (which still use `basestring` elsewhere on peewee 3.x) + are left untouched. + """ + + def __getattr__(self, name): + if name == 'basestring': + raise AttributeError(name) + return getattr(peewee, name) + + +def test_save_only_without_peewee_basestring(monkeypatch): + """Regression test: peewee>=4 removed `peewee.basestring`, which + `Model._validate` used to rely on for `save(only=...)`. Confirm it + still works for both field-name strings and `Field` objects when that + attribute is gone. + """ + monkeypatch.setattr(peeweext.model, 'pw', _PeeweeWithoutBasestring()) + + Note.create_table() + try: + note = Note.create(message='hello') + note.published_at = pendulum.now() + note.save(only=['published_at']) + note.save(only=[Note.published_at]) + finally: + Note.drop_table() + + def test_instance_delete(table): # test delete note = Note.create(message='Hello') From b49533a377d0b62d8fc6f38d4143bba482e9b4f3 Mon Sep 17 00:00:00 2001 From: Neng Wan Date: Fri, 14 Aug 2026 14:59:32 +0800 Subject: [PATCH 2/6] chore: bump version to 1.2.9 Co-Authored-By: Claude --- CHANGELOG.md | 4 ++++ peeweext/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2124e9..d3ea33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. +## [1.2.9] - 2026-08-14 + +- Fix `AttributeError: module 'peewee' has no attribute 'basestring'` raised by `Model.save(only=[...])` on peewee>=4, which dropped the py2-compat `basestring` alias. + ## [1.2.8] - 2024-05-15 - Compatible with both MySQLClient and PyMySQL diff --git a/peeweext/__init__.py b/peeweext/__init__.py index 6a3fa6e..5089ced 100644 --- a/peeweext/__init__.py +++ b/peeweext/__init__.py @@ -1 +1 @@ -__version__ = '1.2.8' +__version__ = '1.2.9' From 8ba5fd391652a826720a218a0b326b8224d628ef Mon Sep 17 00:00:00 2001 From: Neng Wan Date: Fri, 14 Aug 2026 15:14:06 +0800 Subject: [PATCH 3/6] ci: run pytest against peewee<4 and peewee>=4 on push/PR The repo currently has no CI that actually runs the test suite (.github/workflows only covers PyPI publish on release and stale-issue triage; the legacy .travis.yml required check is no longer reported by any active integration). Add a workflow that spins up MySQL and Postgres services and runs `pytest tests` on both sides of the peewee 3/4 compatibility line this branch fixes. Co-Authored-By: Claude --- .github/workflows/test.yml | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ff8c69d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,56 @@ +name: Test + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Guard both sides of the peewee 3/4 compatibility line, since + # peewee is unpinned in requirements.txt and a plain `pip install` + # can resolve to either. + peewee-version: ['peewee<4', 'peewee>=4'] + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: peeweext + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + postgres: + image: postgres:14 + env: + POSTGRES_USER: postgres + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: peeweext + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r test-requirements.txt -U + pip install "${{ matrix.peewee-version }}" + - name: Run tests + run: pytest tests From 64f63bcfc48737b725e795d128145c4ff9db23c7 Mon Sep 17 00:00:00 2001 From: Neng Wan Date: Fri, 14 Aug 2026 15:38:27 +0800 Subject: [PATCH 4/6] ci: pin sea>=4.0.0 in test-requirements pip's resolver was backtracking into sea==3.1.5's old, source-only grpcio<1.49.0 pin, which fails to build on modern Python/setuptools (setuptools dropped pkg_resources, which grpcio's legacy setup.py still imports). sea>=4.0.0 pulls grpcio<1.69.0,>=1.49.0, which has prebuilt wheels. Verified tests/test_sea.py passes locally against sea==4.0.0. Co-Authored-By: Claude --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 1769671..d83594c 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -6,6 +6,6 @@ coveralls codeclimate-test-reporter coverage flask -sea +sea>=4.0.0 psycopg2-binary mysqlclient From f97c146b8cca9538b5d8d63563add06764b7201c Mon Sep 17 00:00:00 2001 From: Neng Wan Date: Fri, 14 Aug 2026 16:07:16 +0800 Subject: [PATCH 5/6] fix(model): search_nullable must be True when discovering delete_instance dependents Model.dependencies(search_nullable) controls which dependent rows peewee even discovers, not just how they get handled. delete_instance() was passing delete_nullable (False by default) as search_nullable, so a nullable FK dependent was never discovered at all, never got nulled out, and the parent delete then failed with a foreign key constraint error. Reproduced against real MySQL 8; the existing test_instance_delete::delete_instance(recursive=True) case now passes. Also fix URLValidator: Python 3.9+'s urlsplit() raises ValueError itself for malformed bracketed IPv6 hosts (e.g. "[::1:2::3]") instead of letting our own ipaddress.IPv6Address check catch it, so that call needs the same try/except ValueError guard already used elsewhere in this validator. Both were long-standing bugs invisible until the new CI workflow (added earlier in this branch) actually ran the suite against real MySQL and Python 3.11 for the first time. Co-Authored-By: Claude --- peeweext/model.py | 7 ++++++- peeweext/validation.py | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/peeweext/model.py b/peeweext/model.py index dd8e7db..79c06c8 100644 --- a/peeweext/model.py +++ b/peeweext/model.py @@ -99,7 +99,12 @@ def delete_instance(self, *args, **kwargs): recursive = kwargs.get('recursive', False) delete_nullable = kwargs.get('delete_nullable', False) if recursive: - dependencies = self.dependencies(delete_nullable) + # search_nullable must always be True here: this only controls + # which dependents peewee *discovers*, and we still need to find + # nullable ones so the branch below can null them out. Whether a + # nullable dependent gets nulled or deleted is governed by + # `delete_nullable` in the loop, not by this call. + dependencies = self.dependencies(True) for query, fk in reversed(list(dependencies)): fk_model = fk.model if fk.null and not delete_nullable: diff --git a/peeweext/validation.py b/peeweext/validation.py index 4393c3f..54b0b53 100644 --- a/peeweext/validation.py +++ b/peeweext/validation.py @@ -128,8 +128,12 @@ def validate(self, value): raise else: # Now verify IPv6 in the netloc part + try: + netloc = urlsplit(value).netloc + except ValueError: # for example, "Invalid IPv6 URL" + raise ValidationError(self.message) host_match = re.search( - r'^\[(.+)\](?::\d{2,5})?$', urlsplit(value).netloc) + r'^\[(.+)\](?::\d{2,5})?$', netloc) if host_match: potential_ip = host_match.groups()[0] try: From bddecc96d02b1f495c7d5bff7b8e0af387836b37 Mon Sep 17 00:00:00 2001 From: Neng Wan Date: Fri, 14 Aug 2026 16:17:34 +0800 Subject: [PATCH 6/6] docs: update CHANGELOG for 1.2.9 with all three fixes Co-Authored-By: Claude --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ea33e..4657483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ## [1.2.9] - 2026-08-14 - Fix `AttributeError: module 'peewee' has no attribute 'basestring'` raised by `Model.save(only=[...])` on peewee>=4, which dropped the py2-compat `basestring` alias. +- Fix `Model.delete_instance(recursive=True)` failing with a foreign key constraint error: nullable FK dependents were never discovered (and thus never nulled out) because `search_nullable` was incorrectly tied to `delete_nullable`. +- Fix `URLValidator` raising an uncaught `ValueError` (instead of `ValidationError`) for malformed bracketed IPv6 hosts like `[::1:2::3]` on Python 3.9+. ## [1.2.8] - 2024-05-15