py4web-debug-tools 1.3.2__tar.gz → 1.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/CHANGELOG.md +14 -0
  2. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/PKG-INFO +3 -1
  3. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/pyproject.toml +6 -0
  4. py4web_debug_tools-1.3.4/src/py4web_debug/__about__.py +1 -0
  5. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/dumping.py +37 -16
  6. py4web_debug_tools-1.3.4/tests/test_dumping.py +120 -0
  7. py4web_debug_tools-1.3.2/src/py4web_debug/__about__.py +0 -1
  8. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/.gitignore +0 -0
  9. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/README.md +0 -0
  10. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/__init__.py +0 -0
  11. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/_internals.py +0 -0
  12. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/core.py +0 -0
  13. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/debugbar.py +0 -0
  14. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/env.py +0 -0
  15. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/internals.py +0 -0
  16. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/py.typed +0 -0
  17. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/templates/debugbar.html +0 -0
  18. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/templates/dumpdie.html +0 -0
  19. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/templates/error_default.html +0 -0
  20. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/templates/fancy_dumpdie.html +0 -0
  21. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/types.py +0 -0
  22. {py4web_debug_tools-1.3.2 → py4web_debug_tools-1.3.4}/src/py4web_debug/wsgi.py +0 -0
@@ -2,6 +2,20 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v1.3.4 (2026-08-06)
6
+
7
+ ### Fix
8
+
9
+ * Improved error message if json failed to `dump()` ([`920b786`](https://github.com/trialandsuccess/py4web-debug-tools/commit/920b78685cfc89cf27269972326562da7c4d05f6))
10
+
11
+ ## v1.3.3 (2026-08-06)
12
+
13
+ ### Fix
14
+
15
+ * Fix JSON dumping for TypeDAL model classes and DotMap-like objects whose dynamic attribute lookup makes hasattr()
16
+ return misleading results. ([PR 2](https://github.com/trialandsuccess/py4web-debug-tools/pull/2))
17
+
18
+
5
19
  ## v1.3.2 (2025-12-12)
6
20
 
7
21
  ### Fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py4web-debug-tools
3
- Version: 1.3.2
3
+ Version: 1.3.4
4
4
  Summary: Debug Tools for py4web
5
5
  Project-URL: Documentation, https://github.com/trialandsuccess/py4web-debug-tools#readme
6
6
  Project-URL: Issues, https://github.com/trialandsuccess/py4web-debug-tools/issues
@@ -21,7 +21,9 @@ Requires-Dist: pydal
21
21
  Requires-Dist: typing-extensions
22
22
  Requires-Dist: yatl
23
23
  Provides-Extra: dev
24
+ Requires-Dist: dotmap; extra == 'dev'
24
25
  Requires-Dist: hatch; extra == 'dev'
26
+ Requires-Dist: pytest; extra == 'dev'
25
27
  Requires-Dist: su6[all]; extra == 'dev'
26
28
  Description-Content-Type: text/markdown
27
29
 
@@ -54,6 +54,8 @@ Source = "https://github.com/trialandsuccess/py4web-debug-tools"
54
54
  dev = [
55
55
  "su6[all]",
56
56
  "hatch",
57
+ "pytest",
58
+ "dotmap",
57
59
  ]
58
60
 
59
61
 
@@ -69,6 +71,10 @@ upload_to_release = false
69
71
  build_command = "hatch build"
70
72
 
71
73
 
74
+ [tool.pytest.ini_options]
75
+ testpaths = ["tests"]
76
+ pythonpath = ["."]
77
+
72
78
  [tool.su6]
73
79
  directory = "src"
74
80
  include = []
@@ -0,0 +1 @@
1
+ __version__ = "1.3.4"
@@ -37,31 +37,30 @@ class PossiblyAsDict(typing.Protocol):
37
37
  class DDJsonEncoder(ConfigurableJsonEncoder):
38
38
  @staticmethod
39
39
  def _default(o: PossiblyAsDict) -> dict[str, typing.Any] | list[typing.Any] | str:
40
- if hasattr(o, "as_list"):
40
+ # note: getattr with default rather than setattr because setattr might lie and produce None
41
+ if getattr(o, "as_list", None):
41
42
  # note: prefer as_list now as not every Rows may have an id (= default key of as_dict)
42
43
  return o.as_list()
43
- # more as list stuff?
44
-
45
- if hasattr(o, "as_dict"):
44
+ elif getattr(o, "as_dict", None):
46
45
  return o.as_dict()
47
- elif hasattr(o, "asdict"):
46
+ elif getattr(o, "asdict", None):
48
47
  return o.asdict()
49
- elif hasattr(o, "_asdict"):
48
+ elif getattr(o, "_asdict", None):
50
49
  return o._asdict()
51
- elif hasattr(o, "_as_dict"):
50
+ elif getattr(o, "_as_dict", None):
52
51
  return o._as_dict()
53
- elif hasattr(o, "to_dict"):
52
+ elif getattr(o, "to_dict", None):
54
53
  return o.to_dict()
55
- elif hasattr(o, "todict"):
54
+ elif getattr(o, "todict", None):
56
55
  return o.todict()
57
- elif hasattr(o, "_todict"):
56
+ elif getattr(o, "_todict", None):
58
57
  return o._todict()
59
- elif hasattr(o, "_to_dict"):
58
+ elif getattr(o, "_to_dict", None):
60
59
  return o._to_dict()
61
- elif hasattr(o, "__json__"):
60
+ elif getattr(o, "__json__", None):
62
61
  return o.__json__()
63
-
64
- return str(o)
62
+ else:
63
+ return str(o)
65
64
 
66
65
  @staticmethod
67
66
  def is_probably_namedtuple(o: typing.Any) -> bool:
@@ -103,8 +102,30 @@ def dump(data: typing.Iterable[typing.Any], with_headers: bool = True) -> str:
103
102
  if with_headers:
104
103
  response.headers["Content-Type"] = "application/json"
105
104
 
106
- return json.dumps(data, indent=2, cls=DDJsonEncoder)
107
- # return json.dumps(data, indent=2, default=DDJsonEncoder._default)
105
+ try:
106
+ return json.dumps(data, indent=2, cls=DDJsonEncoder)
107
+ # return json.dumps(data, indent=2, default=DDJsonEncoder._default)
108
+ except Exception as e:
109
+ raise DumpSerializationError(data, e) from e
110
+
111
+
112
+ class DumpSerializationError(TypeError):
113
+ """
114
+ Raised by dump() when the data (or something nested inside it) could not be
115
+ JSON-serialized, even with DDJsonEncoder's custom object-to-dict fallback rules.
116
+
117
+ Wraps the original error (still available via `__cause__`/`.original`) with
118
+ context about what was being dumped, so this is actionable instead of a bare
119
+ stdlib error surfacing from deep inside json.dumps()/DDJsonEncoder internals.
120
+ """
121
+
122
+ def __init__(self, data: typing.Any, original: Exception) -> None:
123
+ self.data = data
124
+ self.original = original
125
+ super().__init__(
126
+ f"dump() could not JSON-serialize this data (top-level type: {type(data).__name__}): "
127
+ f"{original.__class__.__name__}: {original}"
128
+ )
108
129
 
109
130
 
110
131
  class DumpDieError(Exception):
@@ -0,0 +1,120 @@
1
+ from types import SimpleNamespace
2
+
3
+ import pytest
4
+ from dotmap import DotMap
5
+ from pytest import MonkeyPatch
6
+
7
+ from src.py4web_debug.dumping import DDJsonEncoder, DumpDieError, DumpSerializationError, dd, dump
8
+
9
+
10
+ def _sample_dotmap() -> DotMap:
11
+ data = DotMap()
12
+ data.a = 1
13
+ data.b.c = "nested"
14
+ return data
15
+
16
+
17
+ def test_dump_handles_dotmap():
18
+ # dump() should serialize a DotMap without crashing.
19
+ result = dump(_sample_dotmap(), with_headers=False)
20
+
21
+ assert '"a": 1' in result
22
+ assert '"c": "nested"' in result
23
+
24
+
25
+ def test_dd_handles_dotmap(monkeypatch: MonkeyPatch):
26
+ # dd() should not crash on a DotMap; in debug mode it raises a DumpDieError
27
+ # (by design) instead of letting json serialization blow up.
28
+ monkeypatch.setenv("PY4WEB_DEBUG_MODE", "1")
29
+
30
+ try:
31
+ dd(_sample_dotmap(), fancy=False)
32
+ except DumpDieError as e:
33
+ assert '"a": 1' in str(e)
34
+ else:
35
+ raise AssertionError("dd() should raise DumpDieError in debug mode")
36
+
37
+
38
+ def test_default_crashes_on_bare_dotmap():
39
+ """
40
+ DotMap's __getattr__ returns an empty DotMap for ANY missing attribute
41
+ instead of raising AttributeError, so hasattr(dotmap, "as_list") is
42
+ always True even though `as_list` isn't a real method on it.
43
+
44
+ Note: dump()/dd() never hit this for a *bare* DotMap in practice, because
45
+ DotMap is a dict subclass and gets recognized (and recursed into) by
46
+ configurablejson's encoder before _default() is ever called - see
47
+ test_dump_handles_dotmap above, which passes fine. This test instead
48
+ calls DDJsonEncoder._default() directly, to pin the underlying flaw that
49
+ both this and the TypeDAL-model-class crash below share.
50
+
51
+ FAILS until DDJsonEncoder._default() is fixed:
52
+ TypeError: 'DotMap' object is not callable
53
+ """
54
+ d = DotMap({"label": "Dashboard"})
55
+
56
+ DDJsonEncoder._default(d)
57
+
58
+
59
+ class _TableMetaLike(type):
60
+ """
61
+ Reproduces TypeDAL's `TableMeta.__getattr__`: for an unbound model class,
62
+ any attribute that isn't a real column falls through to `return None`
63
+ instead of raising AttributeError (see typedal/tables.py TableMeta.__getattr__).
64
+ """
65
+
66
+ def __getattr__(cls, name: str) -> None:
67
+ return None
68
+
69
+
70
+ class Offering(metaclass=_TableMetaLike):
71
+ """Stand-in for a real TypeDAL model class, e.g. used as menu["model"]."""
72
+
73
+
74
+ def test_dump_reproduces_as_list_crash_on_model_class_value():
75
+ """
76
+ Real-world crash reproduction (whitelabel AdminMenuFixture):
77
+ admin nav menu entries are plain dicts that carry a `"model"` key holding
78
+ the *model class itself* (e.g. `Offering`, not an instance). Wrapping such
79
+ an entry in a DotMap (for dot-access in templates) doesn't cause this —
80
+ a plain dict crashes identically, see test below.
81
+
82
+ FAILS until DDJsonEncoder._default() is fixed: it does
83
+ `if hasattr(o, "as_list"): o.as_list()` without checking it's callable.
84
+ TypeDAL's TableMeta.__getattr__ returns None for unknown attributes on a
85
+ model class instead of raising AttributeError, so hasattr() lies and
86
+ `o.as_list()` becomes `None()`. Matches production traceback exactly:
87
+ dumping.py:42 in _default -> TypeError: 'NoneType' object is not callable.
88
+ """
89
+ entry = DotMap({"label": "Aanbod beheren", "icon": "fas fa-masks-theater", "model": Offering})
90
+
91
+ dump(entry, with_headers=False)
92
+
93
+
94
+ def test_dump_reproduces_as_list_crash_without_dotmap():
95
+ # Same crash with a plain dict: DotMap is incidental, not the cause.
96
+ entry1 = {"label": "Aanbod beheren", "model": Offering}
97
+ dump(entry1, with_headers=False)
98
+
99
+ entry2 = SimpleNamespace(label="Aanbod beheren", model=Offering)
100
+ dump(entry2, with_headers=False)
101
+
102
+
103
+ def test_dump_wraps_unexpected_errors_with_context():
104
+ """
105
+ Force *some other* JSON-serialization failure (a plain circular reference,
106
+ nothing to do with DotMap/permissive __getattr__) to prove dump() wraps
107
+ whatever goes wrong with a nicer, actionable error instead of leaking a
108
+ bare exception from deep inside json.dumps()/DDJsonEncoder.
109
+ """
110
+ circular: dict = {}
111
+ circular["self"] = circular
112
+
113
+ with pytest.raises(DumpSerializationError) as exc_info:
114
+ dump(circular, with_headers=False)
115
+
116
+ err = exc_info.value
117
+ assert "could not JSON-serialize" in str(err)
118
+ assert "dict" in str(err) # names the top-level type that was being dumped
119
+ assert isinstance(err.__cause__, ValueError) # original error is preserved/chained
120
+ assert "circular" in str(err.__cause__).lower()
@@ -1 +0,0 @@
1
- __version__ = "1.3.2"