From dafcdb059a6df52328cb06ce7cf55ef402ad59a9 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 21 Jun 2026 12:08:38 +0100 Subject: [PATCH 1/2] refactor: make Undefined an internal sentinel Keep the charset Sentinel public while removing Undefined from the package root. Define explicit module export boundaries and cover the public API contract. --- src/qs_codec/__init__.py | 2 -- src/qs_codec/enums/sentinel.py | 38 ++++++++++++------------- src/qs_codec/models/undefined.py | 49 +++++++++++++------------------- tests/unit/package_test.py | 18 ++++++++++++ 4 files changed, 56 insertions(+), 51 deletions(-) create mode 100644 tests/unit/package_test.py diff --git a/src/qs_codec/__init__.py b/src/qs_codec/__init__.py index 982fd88..e95b91e 100644 --- a/src/qs_codec/__init__.py +++ b/src/qs_codec/__init__.py @@ -31,7 +31,6 @@ "Sentinel", "DecodeOptions", "EncodeOptions", - "Undefined", ] from .decode import decode, load, loads @@ -44,4 +43,3 @@ from .enums.sentinel import Sentinel from .models.decode_options import DecodeOptions from .models.encode_options import EncodeOptions -from .models.undefined import Undefined diff --git a/src/qs_codec/enums/sentinel.py b/src/qs_codec/enums/sentinel.py index 7c33484..963f0f2 100644 --- a/src/qs_codec/enums/sentinel.py +++ b/src/qs_codec/enums/sentinel.py @@ -1,24 +1,26 @@ -"""Sentinel values and their percent-encoded forms. +"""Charset sentinel values and their percent-encoded forms. -Browsers sometimes include an ``utf8=…`` “sentinel” in +Browsers sometimes include an ``utf8=…`` sentinel in ``application/x-www-form-urlencoded`` submissions to signal the character -encoding that was used. This module exposes those sentinels as an ``Enum``, -where each member carries both the raw token (what the page emits) and the -fully URL-encoded fragment (what appears on the wire). +encoding that was used. This module exposes those sentinels as an enum whose +members carry both the raw token emitted by the page and the fully URL-encoded +fragment that appears on the wire. """ from dataclasses import dataclass from enum import Enum +__all__ = ("Sentinel",) + @dataclass(frozen=True) class _SentinelDataMixin: - """Common data carried by each sentinel. + """Common data carried by each charset sentinel. Attributes: - raw: The unencoded token browsers start with. For example, the HTML - entity string ``"✓"`` or the literal check mark ``"✓"``. - encoded: The full ``key=value`` fragment after URL-encoding, e.g. + raw: The unencoded token browsers start with, such as the HTML entity + string ``"✓"`` or the literal check mark ``"✓"``. + encoded: The full URL-encoded ``key=value`` fragment, such as ``"utf8=%26%2310003%3B"`` or ``"utf8=%E2%9C%93"``. """ @@ -27,24 +29,22 @@ class _SentinelDataMixin: class Sentinel(_SentinelDataMixin, Enum): - """All supported ``utf8`` sentinels. + """Charset sentinels recognized and emitted by the codec. - Each enum member provides: - - ``raw``: the source token a browser starts with, and - - ``encoded``: the final, percent-encoded ``utf8=…`` fragment. + Each member provides the source token through ``raw`` and the final + percent-encoded ``utf8=…`` fragment through ``encoded``. """ ISO = r"✓", r"utf8=%26%2310003%3B" """HTML-entity sentinel used by non-UTF-8 submissions. - When a check mark (✓) appears but the page/form encoding is ``iso-8859-1`` - (or another charset that lacks ✓), browsers first HTML-entity-escape it as - ``"✓"`` and then URL-encode it, producing ``utf8=%26%2310003%3B``. + When a check mark (✓) appears but the form encoding is ``iso-8859-1`` or + another charset that lacks it, browsers HTML-entity-escape the character + and then URL-encode it, producing ``utf8=%26%2310003%3B``. """ CHARSET = r"✓", r"utf8=%E2%9C%93" - """UTF-8 sentinel indicating the request is UTF-8 encoded. + """UTF-8 sentinel indicating that the request is UTF-8 encoded. - This is the percent-encoded UTF-8 sequence for ✓, yielding the fragment - ``utf8=%E2%9C%93``. + The encoded form contains the percent-encoded UTF-8 sequence for ✓. """ diff --git a/src/qs_codec/models/undefined.py b/src/qs_codec/models/undefined.py index c1dcaa2..4bbfa27 100644 --- a/src/qs_codec/models/undefined.py +++ b/src/qs_codec/models/undefined.py @@ -1,36 +1,25 @@ -"""Undefined sentinel. +"""Internal undefined sentinel used while building codec data structures. -This module defines a tiny singleton `Undefined` used as a *sentinel* to mean “no value provided / omit this key”, -similar to JavaScript’s `undefined`. +``Undefined`` represents a missing value that should be omitted, similar to +JavaScript's ``undefined``. It is distinct from ``None``, which represents an +explicit null value. -Unlike `None` (which commonly means an explicit null), `Undefined` is used by the encoder and helper utilities to *skip* -emitting a key or to signal that a value is intentionally absent and should not be serialized. - -The sentinel is identity-based: every construction returns the same instance, so `is` comparisons are reliable -(e.g., `value is Undefined()`). +The sentinel is identity-based: every construction returns the same instance, +allowing reliable ``is`` comparisons throughout the codec internals. """ import threading import typing as t +__all__ = () + class Undefined: - """Singleton sentinel object representing an “undefined” value. - - Notes: - * This is **not** the same as `None`. Use `None` to represent a *null* value and `Undefined()` to represent “no value / omit”. - * All calls to ``Undefined()`` return the same instance. Prefer identity checks (``is``) over equality checks. - - Examples: - >>> from qs_codec.models.undefined import Undefined - >>> a = Undefined() - >>> b = Undefined() - >>> a is b - True - >>> # Use it to indicate a key should be omitted when encoding: - >>> maybe_value = Undefined() - >>> if maybe_value is Undefined(): - ... pass # skip emitting the key + """Singleton sentinel representing a value that should be omitted. + + This is not equivalent to ``None``. The encoder and helper utilities use it + temporarily to mark absent entries, then remove those entries before + returning public results. """ __slots__ = () @@ -40,7 +29,8 @@ class Undefined: def __new__(cls): """Return the singleton instance. - Creating `Undefined()` multiple times always returns the same object reference. This ensures identity checks (``is``) are stable. + Repeated construction returns the same object reference so identity + checks remain stable. """ if cls._instance is None: with cls._lock: @@ -53,21 +43,20 @@ def __repr__(self) -> str: # pragma: no cover - trivial return "Undefined()" def __copy__(self): # pragma: no cover - trivial - """Ensure copies/pickles preserve the singleton identity.""" + """Preserve singleton identity when shallow-copied.""" return self def __deepcopy__(self, memo): # pragma: no cover - trivial - """Ensure deep copies preserve the singleton identity.""" + """Preserve singleton identity when deep-copied.""" return self def __reduce__(self): # pragma: no cover - trivial - """Recreate via calling the constructor, which returns the singleton.""" + """Reconstruct through the singleton constructor when unpickled.""" return Undefined, () def __init_subclass__(cls, **kwargs): # pragma: no cover - defensive - """Prevent subclassing of Undefined.""" + """Prevent subclassing to preserve singleton identity.""" raise TypeError("Undefined cannot be subclassed") UNDEFINED: t.Final["Undefined"] = Undefined() -__all__ = ["Undefined", "UNDEFINED"] diff --git a/tests/unit/package_test.py b/tests/unit/package_test.py new file mode 100644 index 0000000..9c050fb --- /dev/null +++ b/tests/unit/package_test.py @@ -0,0 +1,18 @@ +import qs_codec +import qs_codec.enums.sentinel as sentinel_module +import qs_codec.models.undefined as undefined_module + + +def test_sentinel_is_part_of_the_public_api(): + assert "Sentinel" in qs_codec.__all__ + assert qs_codec.Sentinel is sentinel_module.Sentinel + + +def test_undefined_is_not_part_of_the_public_api(): + assert "Undefined" not in qs_codec.__all__ + assert not hasattr(qs_codec, "Undefined") + + +def test_sentinel_modules_define_their_intended_exports(): + assert sentinel_module.__all__ == ("Sentinel",) + assert undefined_module.__all__ == () From 8f55f8d4703c6751cf195cbe27b48a7eb9a78377 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 21 Jun 2026 12:08:53 +0100 Subject: [PATCH 2/2] docs: remove public Undefined references Remove the public encoding example and generated API entry, and record the API cleanup for 1.6.0-dev. --- CHANGELOG.md | 4 ++++ README.rst | 8 -------- docs/README.rst | 8 -------- docs/qs_codec.models.rst | 8 -------- tests/unit/example_test.py | 3 --- 5 files changed, 4 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c7dd0..23ddd55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.6.0-dev + +* [CHORE] make `Undefined` internal by removing `qs_codec.Undefined` and its public documentation + ## 1.5.2 * [CHORE] simplify mapping and key-iteration checks in encode/decode internals diff --git a/README.rst b/README.rst index 82f5ee1..7d540ad 100644 --- a/README.rst +++ b/README.rst @@ -750,14 +750,6 @@ Keys with no values (such as an empty ``dict`` or ``list``) will return nothing: assert qs.encode({'a': {'b': {}}}) == '' -`Undefined `__ properties will be omitted entirely: - -.. code:: python - - import qs_codec as qs - - assert qs.encode({'a': None, 'b': qs.Undefined()}) == 'a=' - The query string may optionally be prepended with a question mark (``?``) by setting `add_query_prefix `__ to ``True``: diff --git a/docs/README.rst b/docs/README.rst index e3fa63f..0d693ab 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -744,14 +744,6 @@ Keys with no values (such as an empty ``dict`` or ``list``) will return nothing: assert qs.encode({'a': {'b': {}}}) == '' -:py:attr:`Undefined ` properties will be omitted entirely: - -.. code:: python - - import qs_codec as qs - - assert qs.encode({'a': None, 'b': qs.Undefined()}) == 'a=' - The query string may optionally be prepended with a question mark (``?``) by setting :py:attr:`add_query_prefix ` to ``True``: diff --git a/docs/qs_codec.models.rst b/docs/qs_codec.models.rst index 0cc38fd..f21c94d 100644 --- a/docs/qs_codec.models.rst +++ b/docs/qs_codec.models.rst @@ -20,14 +20,6 @@ qs\_codec.models.encode\_options module :undoc-members: :show-inheritance: -qs\_codec.models.undefined module ---------------------------------- - -.. automodule:: qs_codec.models.undefined - :members: - :undoc-members: - :show-inheritance: - qs\_codec.models.weak\_wrapper module ------------------------------------- diff --git a/tests/unit/example_test.py b/tests/unit/example_test.py index b27ccc7..b918778 100644 --- a/tests/unit/example_test.py +++ b/tests/unit/example_test.py @@ -271,9 +271,6 @@ def custom_decoder(value: t.Any, charset: t.Optional[qs_codec.Charset]): assert qs_codec.encode({"a": {"b": []}}) == "" assert qs_codec.encode({"a": {"b": {}}}) == "" - # Properties that are `Undefined` will be omitted entirely: - assert qs_codec.encode({"a": None, "b": qs_codec.Undefined()}) == "a=" - # The query string may optionally be prepended with a question mark: assert qs_codec.encode({"a": "b", "c": "d"}, qs_codec.EncodeOptions(add_query_prefix=True)) == "?a=b&c=d"