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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 0 additions & 8 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -750,14 +750,6 @@ Keys with no values (such as an empty ``dict`` or ``list``) will return nothing:

assert qs.encode({'a': {'b': {}}}) == ''

`Undefined <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.undefined.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 <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.encode_options.EncodeOptions.add_query_prefix>`__ to ``True``:

Expand Down
8 changes: 0 additions & 8 deletions docs/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <qs_codec.models.undefined.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 <qs_codec.models.encode_options.EncodeOptions.add_query_prefix>` to ``True``:

Expand Down
8 changes: 0 additions & 8 deletions docs/qs_codec.models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------------------------

Expand Down
2 changes: 0 additions & 2 deletions src/qs_codec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"Sentinel",
"DecodeOptions",
"EncodeOptions",
"Undefined",
]

from .decode import decode, load, loads
Expand All @@ -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
38 changes: 19 additions & 19 deletions src/qs_codec/enums/sentinel.py
Original file line number Diff line number Diff line change
@@ -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 ``"&#10003;"`` 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 ``"&#10003;"`` or the literal check mark ``"✓"``.
encoded: The full URL-encoded ``key=value`` fragment, such as
``"utf8=%26%2310003%3B"`` or ``"utf8=%E2%9C%93"``.
"""

Expand All @@ -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"&#10003;", 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
``"&#10003;"`` 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 ✓.
"""
49 changes: 19 additions & 30 deletions src/qs_codec/models/undefined.py
Original file line number Diff line number Diff line change
@@ -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__ = ()
Expand All @@ -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:
Expand All @@ -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"]
3 changes: 0 additions & 3 deletions tests/unit/example_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
18 changes: 18 additions & 0 deletions tests/unit/package_test.py
Original file line number Diff line number Diff line change
@@ -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__ == ()