etlplus 0.9.2__py3-none-any.whl → 0.10.2__py3-none-any.whl

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 (120) hide show
  1. etlplus/__init__.py +26 -1
  2. etlplus/api/README.md +3 -51
  3. etlplus/api/__init__.py +0 -10
  4. etlplus/api/config.py +28 -39
  5. etlplus/api/endpoint_client.py +3 -3
  6. etlplus/api/pagination/client.py +1 -1
  7. etlplus/api/rate_limiting/config.py +1 -13
  8. etlplus/api/rate_limiting/rate_limiter.py +11 -8
  9. etlplus/api/request_manager.py +6 -11
  10. etlplus/api/transport.py +2 -14
  11. etlplus/api/types.py +6 -96
  12. etlplus/cli/commands.py +43 -76
  13. etlplus/cli/constants.py +1 -1
  14. etlplus/cli/handlers.py +12 -40
  15. etlplus/cli/io.py +2 -2
  16. etlplus/cli/main.py +1 -1
  17. etlplus/cli/state.py +7 -4
  18. etlplus/{workflow → config}/__init__.py +23 -10
  19. etlplus/{workflow → config}/connector.py +44 -58
  20. etlplus/{workflow → config}/jobs.py +32 -105
  21. etlplus/{workflow → config}/pipeline.py +51 -59
  22. etlplus/{workflow → config}/profile.py +5 -8
  23. etlplus/config/types.py +204 -0
  24. etlplus/config/utils.py +120 -0
  25. etlplus/database/ddl.py +1 -1
  26. etlplus/database/engine.py +3 -19
  27. etlplus/database/orm.py +0 -2
  28. etlplus/database/schema.py +1 -1
  29. etlplus/enums.py +288 -0
  30. etlplus/{ops/extract.py → extract.py} +99 -81
  31. etlplus/file.py +652 -0
  32. etlplus/{ops/load.py → load.py} +101 -78
  33. etlplus/{ops/run.py → run.py} +127 -159
  34. etlplus/{api/utils.py → run_helpers.py} +153 -209
  35. etlplus/{ops/transform.py → transform.py} +68 -75
  36. etlplus/types.py +4 -5
  37. etlplus/utils.py +2 -136
  38. etlplus/{ops/validate.py → validate.py} +12 -22
  39. etlplus/validation/__init__.py +44 -0
  40. etlplus/{ops → validation}/utils.py +17 -53
  41. {etlplus-0.9.2.dist-info → etlplus-0.10.2.dist-info}/METADATA +17 -210
  42. etlplus-0.10.2.dist-info/RECORD +65 -0
  43. {etlplus-0.9.2.dist-info → etlplus-0.10.2.dist-info}/WHEEL +1 -1
  44. etlplus/README.md +0 -37
  45. etlplus/api/enums.py +0 -51
  46. etlplus/cli/README.md +0 -40
  47. etlplus/database/README.md +0 -48
  48. etlplus/file/README.md +0 -105
  49. etlplus/file/__init__.py +0 -25
  50. etlplus/file/_imports.py +0 -141
  51. etlplus/file/_io.py +0 -160
  52. etlplus/file/accdb.py +0 -78
  53. etlplus/file/arrow.py +0 -78
  54. etlplus/file/avro.py +0 -176
  55. etlplus/file/bson.py +0 -77
  56. etlplus/file/cbor.py +0 -78
  57. etlplus/file/cfg.py +0 -79
  58. etlplus/file/conf.py +0 -80
  59. etlplus/file/core.py +0 -322
  60. etlplus/file/csv.py +0 -79
  61. etlplus/file/dat.py +0 -78
  62. etlplus/file/dta.py +0 -77
  63. etlplus/file/duckdb.py +0 -78
  64. etlplus/file/enums.py +0 -343
  65. etlplus/file/feather.py +0 -111
  66. etlplus/file/fwf.py +0 -77
  67. etlplus/file/gz.py +0 -123
  68. etlplus/file/hbs.py +0 -78
  69. etlplus/file/hdf5.py +0 -78
  70. etlplus/file/ini.py +0 -79
  71. etlplus/file/ion.py +0 -78
  72. etlplus/file/jinja2.py +0 -78
  73. etlplus/file/json.py +0 -98
  74. etlplus/file/log.py +0 -78
  75. etlplus/file/mat.py +0 -78
  76. etlplus/file/mdb.py +0 -78
  77. etlplus/file/msgpack.py +0 -78
  78. etlplus/file/mustache.py +0 -78
  79. etlplus/file/nc.py +0 -78
  80. etlplus/file/ndjson.py +0 -108
  81. etlplus/file/numbers.py +0 -75
  82. etlplus/file/ods.py +0 -79
  83. etlplus/file/orc.py +0 -111
  84. etlplus/file/parquet.py +0 -113
  85. etlplus/file/pb.py +0 -78
  86. etlplus/file/pbf.py +0 -77
  87. etlplus/file/properties.py +0 -78
  88. etlplus/file/proto.py +0 -77
  89. etlplus/file/psv.py +0 -79
  90. etlplus/file/rda.py +0 -78
  91. etlplus/file/rds.py +0 -78
  92. etlplus/file/sas7bdat.py +0 -78
  93. etlplus/file/sav.py +0 -77
  94. etlplus/file/sqlite.py +0 -78
  95. etlplus/file/stub.py +0 -84
  96. etlplus/file/sylk.py +0 -77
  97. etlplus/file/tab.py +0 -81
  98. etlplus/file/toml.py +0 -78
  99. etlplus/file/tsv.py +0 -80
  100. etlplus/file/txt.py +0 -102
  101. etlplus/file/vm.py +0 -78
  102. etlplus/file/wks.py +0 -77
  103. etlplus/file/xls.py +0 -88
  104. etlplus/file/xlsm.py +0 -79
  105. etlplus/file/xlsx.py +0 -99
  106. etlplus/file/xml.py +0 -185
  107. etlplus/file/xpt.py +0 -78
  108. etlplus/file/yaml.py +0 -95
  109. etlplus/file/zip.py +0 -175
  110. etlplus/file/zsav.py +0 -77
  111. etlplus/ops/README.md +0 -50
  112. etlplus/ops/__init__.py +0 -61
  113. etlplus/templates/README.md +0 -46
  114. etlplus/workflow/README.md +0 -52
  115. etlplus/workflow/dag.py +0 -105
  116. etlplus/workflow/types.py +0 -115
  117. etlplus-0.9.2.dist-info/RECORD +0 -134
  118. {etlplus-0.9.2.dist-info → etlplus-0.10.2.dist-info}/entry_points.txt +0 -0
  119. {etlplus-0.9.2.dist-info → etlplus-0.10.2.dist-info}/licenses/LICENSE +0 -0
  120. {etlplus-0.9.2.dist-info → etlplus-0.10.2.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,12 @@
1
1
  """
2
- :mod:`etlplus.workflow.jobs` module.
2
+ :mod:`etlplus.config.jobs` module.
3
3
 
4
4
  Data classes modeling job orchestration references (extract, validate,
5
5
  transform, load).
6
6
 
7
7
  Notes
8
8
  -----
9
- - Lightweight references used inside :class:`PipelineConfig` to avoid storing
9
+ - Lightweight references used inside ``PipelineConfig`` to avoid storing
10
10
  large nested structures.
11
11
  - All attributes are simple and optional where appropriate, keeping parsing
12
12
  tolerant.
@@ -19,7 +19,6 @@ from dataclasses import field
19
19
  from typing import Any
20
20
  from typing import Self
21
21
 
22
- from ..types import StrAnyMap
23
22
  from ..utils import coerce_dict
24
23
  from ..utils import maybe_mapping
25
24
 
@@ -27,7 +26,6 @@ from ..utils import maybe_mapping
27
26
 
28
27
 
29
28
  __all__ = [
30
- # Data Classes
31
29
  'ExtractRef',
32
30
  'JobConfig',
33
31
  'LoadRef',
@@ -36,76 +34,10 @@ __all__ = [
36
34
  ]
37
35
 
38
36
 
39
- # SECTION: INTERNAL FUNCTIONS =============================================== #
37
+ # SECTION: TYPE ALIASES ===================================================== #
40
38
 
41
39
 
42
- def _coerce_optional_str(value: Any) -> str | None:
43
- """
44
- Normalize optional string values, coercing non-strings when needed.
45
-
46
- Parameters
47
- ----------
48
- value : Any
49
- Optional value to normalize.
50
-
51
- Returns
52
- -------
53
- str | None
54
- ``None`` when ``value`` is ``None``; otherwise a string value.
55
- """
56
- if value is None:
57
- return None
58
- return value if isinstance(value, str) else str(value)
59
-
60
-
61
- def _parse_depends_on(
62
- value: Any,
63
- ) -> list[str]:
64
- """
65
- Normalize dependency declarations into a string list.
66
-
67
- Parameters
68
- ----------
69
- value : Any
70
- Input dependency specification (string or list of strings).
71
-
72
- Returns
73
- -------
74
- list[str]
75
- Normalized dependency list.
76
- """
77
- if isinstance(value, str):
78
- return [value]
79
- if isinstance(value, list):
80
- return [entry for entry in value if isinstance(entry, str)]
81
- return []
82
-
83
-
84
- def _require_str(
85
- # data: dict[str, Any],
86
- data: StrAnyMap,
87
- key: str,
88
- ) -> str | None:
89
- """
90
- Extract a required string field from a mapping.
91
-
92
- Parameters
93
- ----------
94
- data : StrAnyMap
95
- Mapping containing the target field.
96
- key : str
97
- Field name to extract.
98
-
99
- Returns
100
- -------
101
- str | None
102
- The string value when present and valid; otherwise ``None``.
103
- """
104
- value = data.get(key)
105
- return value if isinstance(value, str) else None
106
-
107
-
108
- # SECTION: DATA CLASSES ===================================================== #
40
+ # SECTION: CLASSES ========================================================== #
109
41
 
110
42
 
111
43
  @dataclass(kw_only=True, slots=True)
@@ -133,13 +65,12 @@ class ExtractRef:
133
65
  cls,
134
66
  obj: Any,
135
67
  ) -> Self | None:
136
- """
137
- Parse a mapping into an :class:`ExtractRef` instance.
68
+ """Parse a mapping into an :class:`ExtractRef` instance.
138
69
 
139
70
  Parameters
140
71
  ----------
141
72
  obj : Any
142
- Mapping with :attr:`source` and optional :attr:`options`.
73
+ Mapping with ``source`` and optional ``options``.
143
74
 
144
75
  Returns
145
76
  -------
@@ -149,8 +80,8 @@ class ExtractRef:
149
80
  data = maybe_mapping(obj)
150
81
  if not data:
151
82
  return None
152
- source = _require_str(data, 'source')
153
- if source is None:
83
+ source = data.get('source')
84
+ if not isinstance(source, str):
154
85
  return None
155
86
  return cls(
156
87
  source=source,
@@ -169,8 +100,6 @@ class JobConfig:
169
100
  Unique job name.
170
101
  description : str | None
171
102
  Optional human-friendly description.
172
- depends_on : list[str]
173
- Optional job dependency list. Dependencies must refer to other jobs.
174
103
  extract : ExtractRef | None
175
104
  Extraction reference.
176
105
  validate : ValidationRef | None
@@ -185,7 +114,6 @@ class JobConfig:
185
114
 
186
115
  name: str
187
116
  description: str | None = None
188
- depends_on: list[str] = field(default_factory=list)
189
117
  extract: ExtractRef | None = None
190
118
  validate: ValidationRef | None = None
191
119
  transform: TransformRef | None = None
@@ -198,8 +126,7 @@ class JobConfig:
198
126
  cls,
199
127
  obj: Any,
200
128
  ) -> Self | None:
201
- """
202
- Parse a mapping into a :class:`JobConfig` instance.
129
+ """Parse a mapping into a :class:`JobConfig` instance.
203
130
 
204
131
  Parameters
205
132
  ----------
@@ -214,18 +141,17 @@ class JobConfig:
214
141
  data = maybe_mapping(obj)
215
142
  if not data:
216
143
  return None
217
- name = _require_str(data, 'name')
218
- if name is None:
144
+ name = data.get('name')
145
+ if not isinstance(name, str):
219
146
  return None
220
147
 
221
- description = _coerce_optional_str(data.get('description'))
222
-
223
- depends_on = _parse_depends_on(data.get('depends_on'))
148
+ description = data.get('description')
149
+ if description is not None and not isinstance(description, str):
150
+ description = str(description)
224
151
 
225
152
  return cls(
226
153
  name=name,
227
154
  description=description,
228
- depends_on=depends_on,
229
155
  extract=ExtractRef.from_obj(data.get('extract')),
230
156
  validate=ValidationRef.from_obj(data.get('validate')),
231
157
  transform=TransformRef.from_obj(data.get('transform')),
@@ -258,13 +184,12 @@ class LoadRef:
258
184
  cls,
259
185
  obj: Any,
260
186
  ) -> Self | None:
261
- """
262
- Parse a mapping into a :class:`LoadRef` instance.
187
+ """Parse a mapping into a :class:`LoadRef` instance.
263
188
 
264
189
  Parameters
265
190
  ----------
266
191
  obj : Any
267
- Mapping with :attr:`target` and optional :attr:`overrides`.
192
+ Mapping with ``target`` and optional ``overrides``.
268
193
 
269
194
  Returns
270
195
  -------
@@ -274,8 +199,8 @@ class LoadRef:
274
199
  data = maybe_mapping(obj)
275
200
  if not data:
276
201
  return None
277
- target = _require_str(data, 'target')
278
- if target is None:
202
+ target = data.get('target')
203
+ if not isinstance(target, str):
279
204
  return None
280
205
  return cls(
281
206
  target=target,
@@ -305,13 +230,12 @@ class TransformRef:
305
230
  cls,
306
231
  obj: Any,
307
232
  ) -> Self | None:
308
- """
309
- Parse a mapping into a :class:`TransformRef` instance.
233
+ """Parse a mapping into a :class:`TransformRef` instance.
310
234
 
311
235
  Parameters
312
236
  ----------
313
237
  obj : Any
314
- Mapping with :attr:`pipeline`.
238
+ Mapping with ``pipeline``.
315
239
 
316
240
  Returns
317
241
  -------
@@ -321,8 +245,8 @@ class TransformRef:
321
245
  data = maybe_mapping(obj)
322
246
  if not data:
323
247
  return None
324
- pipeline = _require_str(data, 'pipeline')
325
- if pipeline is None:
248
+ pipeline = data.get('pipeline')
249
+ if not isinstance(pipeline, str):
326
250
  return None
327
251
  return cls(pipeline=pipeline)
328
252
 
@@ -356,13 +280,12 @@ class ValidationRef:
356
280
  cls,
357
281
  obj: Any,
358
282
  ) -> Self | None:
359
- """
360
- Parse a mapping into a :class:`ValidationRef` instance.
283
+ """Parse a mapping into a :class:`ValidationRef` instance.
361
284
 
362
285
  Parameters
363
286
  ----------
364
287
  obj : Any
365
- Mapping with :attr:`ruleset` plus optional metadata.
288
+ Mapping with ``ruleset`` plus optional metadata.
366
289
 
367
290
  Returns
368
291
  -------
@@ -372,11 +295,15 @@ class ValidationRef:
372
295
  data = maybe_mapping(obj)
373
296
  if not data:
374
297
  return None
375
- ruleset = _require_str(data, 'ruleset')
376
- if ruleset is None:
298
+ ruleset = data.get('ruleset')
299
+ if not isinstance(ruleset, str):
377
300
  return None
378
- severity = _coerce_optional_str(data.get('severity'))
379
- phase = _coerce_optional_str(data.get('phase'))
301
+ severity = data.get('severity')
302
+ if severity is not None and not isinstance(severity, str):
303
+ severity = str(severity)
304
+ phase = data.get('phase')
305
+ if phase is not None and not isinstance(phase, str):
306
+ phase = str(phase)
380
307
  return cls(
381
308
  ruleset=ruleset,
382
309
  severity=severity,
@@ -1,5 +1,5 @@
1
1
  """
2
- :mod:`etlplus.workflow.pipeline` module.
2
+ :mod:`etlplus.config.pipeline` module.
3
3
 
4
4
  Pipeline configuration model and helpers for job orchestration.
5
5
 
@@ -16,7 +16,6 @@ Notes
16
16
  from __future__ import annotations
17
17
 
18
18
  import os
19
- from collections.abc import Callable
20
19
  from collections.abc import Mapping
21
20
  from dataclasses import dataclass
22
21
  from dataclasses import field
@@ -25,90 +24,72 @@ from typing import Any
25
24
  from typing import Self
26
25
 
27
26
  from ..api import ApiConfig
27
+ from ..enums import FileFormat
28
28
  from ..file import File
29
- from ..file import FileFormat
30
29
  from ..types import StrAnyMap
31
30
  from ..utils import coerce_dict
32
- from ..utils import deep_substitute
33
31
  from ..utils import maybe_mapping
34
32
  from .connector import Connector
35
33
  from .connector import parse_connector
36
34
  from .jobs import JobConfig
37
35
  from .profile import ProfileConfig
36
+ from .utils import deep_substitute
38
37
 
39
38
  # SECTION: EXPORTS ========================================================== #
40
39
 
41
40
 
42
- __all__ = [
43
- # Data Classes
44
- 'PipelineConfig',
45
- # Functions
46
- 'load_pipeline_config',
47
- ]
41
+ __all__ = ['PipelineConfig', 'load_pipeline_config']
48
42
 
49
43
 
50
- # SECTION: INTERNAL FUNCTIONS =============================================== #
51
-
52
-
53
- def _collect_parsed[T](
44
+ def _build_jobs(
54
45
  raw: StrAnyMap,
55
- key: str,
56
- parser: Callable[[Any], T | None],
57
- ) -> list[T]:
46
+ ) -> list[JobConfig]:
58
47
  """
59
- Collect parsed items from ``raw[key]`` using a tolerant parser.
48
+ Return a list of ``JobConfig`` objects parsed from the mapping.
60
49
 
61
50
  Parameters
62
51
  ----------
63
52
  raw : StrAnyMap
64
53
  Raw pipeline mapping.
65
- key : str
66
- Key pointing to a list-like payload.
67
- parser : Callable[[Any], T | None]
68
- Parser that returns an instance or ``None`` for invalid entries.
69
54
 
70
55
  Returns
71
56
  -------
72
- list[T]
73
- Parsed items, excluding invalid entries.
57
+ list[JobConfig]
58
+ Parsed job configurations.
74
59
  """
75
- items: list[T] = []
76
- for entry in raw.get(key, []) or []:
77
- parsed = parser(entry)
78
- if parsed is not None:
79
- items.append(parsed)
80
- return items
60
+ jobs: list[JobConfig] = []
61
+ for job_raw in raw.get('jobs', []) or []:
62
+ job_cfg = JobConfig.from_obj(job_raw)
63
+ if job_cfg is not None:
64
+ jobs.append(job_cfg)
65
+
66
+ return jobs
81
67
 
82
68
 
83
- def _parse_connector_entry(
84
- obj: Any,
85
- ) -> Connector | None:
69
+ def _build_sources(
70
+ raw: StrAnyMap,
71
+ ) -> list[Connector]:
86
72
  """
87
- Parse a connector mapping into a concrete connector instance.
73
+ Return a list of source connectors parsed from the mapping.
88
74
 
89
75
  Parameters
90
76
  ----------
91
- obj : Any
92
- Candidate connector mapping.
77
+ raw : StrAnyMap
78
+ Raw pipeline mapping.
93
79
 
94
80
  Returns
95
81
  -------
96
- Connector | None
97
- Parsed connector instance or ``None`` when invalid.
82
+ list[Connector]
83
+ Parsed source connectors.
98
84
  """
99
- if not (entry := maybe_mapping(obj)):
100
- return None
101
- try:
102
- return parse_connector(entry)
103
- except TypeError:
104
- return None
85
+ return _build_connectors(raw, 'sources')
105
86
 
106
87
 
107
- def _build_sources(
88
+ def _build_targets(
108
89
  raw: StrAnyMap,
109
90
  ) -> list[Connector]:
110
91
  """
111
- Return a list of source connectors parsed from the mapping.
92
+ Return a list of target connectors parsed from the mapping.
112
93
 
113
94
  Parameters
114
95
  ----------
@@ -118,32 +99,43 @@ def _build_sources(
118
99
  Returns
119
100
  -------
120
101
  list[Connector]
121
- Parsed source connectors.
102
+ Parsed target connectors.
122
103
  """
123
- return list(
124
- _collect_parsed(raw, 'sources', _parse_connector_entry),
125
- )
104
+ return _build_connectors(raw, 'targets')
126
105
 
127
106
 
128
- def _build_targets(
107
+ def _build_connectors(
129
108
  raw: StrAnyMap,
109
+ key: str,
130
110
  ) -> list[Connector]:
131
111
  """
132
- Return a list of target connectors parsed from the mapping.
112
+ Return parsed connectors from ``raw[key]`` using tolerant parsing.
113
+
114
+ Unknown or malformed entries are skipped to preserve permissiveness.
133
115
 
134
116
  Parameters
135
117
  ----------
136
118
  raw : StrAnyMap
137
119
  Raw pipeline mapping.
120
+ key : str
121
+ List-containing top-level key ("sources" or "targets").
138
122
 
139
123
  Returns
140
124
  -------
141
125
  list[Connector]
142
- Parsed target connectors.
126
+ Constructed connector instances (malformed entries skipped).
143
127
  """
144
- return list(
145
- _collect_parsed(raw, 'targets', _parse_connector_entry),
146
- )
128
+ items: list[Connector] = []
129
+ for obj in raw.get(key, []) or []:
130
+ if not (entry := maybe_mapping(obj)):
131
+ continue
132
+ try:
133
+ items.append(parse_connector(entry))
134
+ except TypeError:
135
+ # Skip unsupported types or malformed entries
136
+ continue
137
+
138
+ return items
147
139
 
148
140
 
149
141
  # SECTION: FUNCTIONS ======================================================== #
@@ -164,7 +156,7 @@ def load_pipeline_config(
164
156
  return PipelineConfig.from_yaml(path, substitute=substitute, env=env)
165
157
 
166
158
 
167
- # SECTION: DATA CLASSES ===================================================== #
159
+ # SECTION: CLASSES ========================================================== #
168
160
 
169
161
 
170
162
  @dataclass(kw_only=True, slots=True)
@@ -254,7 +246,7 @@ class PipelineConfig:
254
246
  TypeError
255
247
  If the YAML root is not a mapping/object.
256
248
  """
257
- raw = File(Path(path), FileFormat.YAML).read()
249
+ raw = File(Path(path), FileFormat.YAML).read_yaml()
258
250
  if not isinstance(raw, dict):
259
251
  raise TypeError('Pipeline YAML must have a mapping/object root')
260
252
 
@@ -321,7 +313,7 @@ class PipelineConfig:
321
313
  targets = _build_targets(raw)
322
314
 
323
315
  # Jobs
324
- jobs = _collect_parsed(raw, 'jobs', JobConfig.from_obj)
316
+ jobs = _build_jobs(raw)
325
317
 
326
318
  # Table schemas (optional, tolerant pass-through structures).
327
319
  table_schemas: list[dict[str, Any]] = []
@@ -1,5 +1,5 @@
1
1
  """
2
- :mod:`etlplus.workflow.profile` module.
2
+ :mod:`etlplus.config.profile` module.
3
3
 
4
4
  Profile model for pipeline-level defaults and environment.
5
5
 
@@ -22,13 +22,10 @@ from ..utils import cast_str_dict
22
22
  # SECTION: EXPORTS ========================================================== #
23
23
 
24
24
 
25
- __all__ = [
26
- # Data Classes
27
- 'ProfileConfig',
28
- ]
25
+ __all__ = ['ProfileConfig']
29
26
 
30
27
 
31
- # SECTION: DATA CLASSES ===================================================== #
28
+ # SECTION: CLASSES ========================================================== #
32
29
 
33
30
 
34
31
  @dataclass(kw_only=True, slots=True)
@@ -56,7 +53,7 @@ class ProfileConfig:
56
53
  cls,
57
54
  obj: StrAnyMap | None,
58
55
  ) -> Self:
59
- """Parse a mapping into a :class:`ProfileConfig` instance.
56
+ """Parse a mapping into a ``ProfileConfig`` instance.
60
57
 
61
58
  Parameters
62
59
  ----------
@@ -67,7 +64,7 @@ class ProfileConfig:
67
64
  -------
68
65
  Self
69
66
  Parsed profile configuration; non-mapping input yields a default
70
- instance. All :attr:`env` values are coerced to strings.
67
+ instance. All ``env`` values are coerced to strings.
71
68
  """
72
69
  if not isinstance(obj, Mapping):
73
70
  return cls()