sciris 3.4.0__tar.gz → 3.5.1__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 (173) hide show
  1. {sciris-3.4.0 → sciris-3.5.1}/CHANGELOG.md +14 -0
  2. {sciris-3.4.0 → sciris-3.5.1}/CLAUDE.md +1 -1
  3. {sciris-3.4.0/sciris.egg-info → sciris-3.5.1}/PKG-INFO +1 -1
  4. {sciris-3.4.0 → sciris-3.5.1}/docs/.gitignore +5 -0
  5. {sciris-3.4.0 → sciris-3.5.1}/docs/index.qmd +1 -1
  6. {sciris-3.4.0 → sciris-3.5.1}/docs/make_api.py +4 -47
  7. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_dicts.qmd +13 -1
  8. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_datetime.py +24 -12
  9. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_nested.py +38 -37
  10. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_odict.py +63 -69
  11. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_version.py +2 -2
  12. {sciris-3.4.0 → sciris-3.5.1/sciris.egg-info}/PKG-INFO +1 -1
  13. {sciris-3.4.0 → sciris-3.5.1}/sciris.egg-info/SOURCES.txt +1 -3
  14. sciris-3.5.1/sciris_issues.md +21 -0
  15. {sciris-3.4.0 → sciris-3.5.1}/tests/test_api.py +1 -16
  16. {sciris-3.4.0 → sciris-3.5.1}/tests/test_datetime.py +7 -0
  17. {sciris-3.4.0 → sciris-3.5.1}/tests/test_nested.py +2 -0
  18. {sciris-3.4.0 → sciris-3.5.1}/tests/test_odict.py +7 -0
  19. sciris-3.4.0/docs/api.json +0 -2824
  20. sciris-3.4.0/docs/llms-full.txt +0 -2120
  21. sciris-3.4.0/docs/llms.txt +0 -342
  22. {sciris-3.4.0 → sciris-3.5.1}/.claude-plugin/marketplace.json +0 -0
  23. {sciris-3.4.0 → sciris-3.5.1}/.github/workflows/publish_docs.yaml +0 -0
  24. {sciris-3.4.0 → sciris-3.5.1}/.github/workflows/pypi_release.yaml +0 -0
  25. {sciris-3.4.0 → sciris-3.5.1}/.github/workflows/schedule_tests.yaml +0 -0
  26. {sciris-3.4.0 → sciris-3.5.1}/.github/workflows/test_docs.yaml +0 -0
  27. {sciris-3.4.0 → sciris-3.5.1}/.github/workflows/test_downstream.yaml +0 -0
  28. {sciris-3.4.0 → sciris-3.5.1}/.github/workflows/test_sciris.yaml +0 -0
  29. {sciris-3.4.0 → sciris-3.5.1}/.gitignore +0 -0
  30. {sciris-3.4.0 → sciris-3.5.1}/.pylintrc +0 -0
  31. {sciris-3.4.0 → sciris-3.5.1}/.pytest_cache/README.md +0 -0
  32. {sciris-3.4.0 → sciris-3.5.1}/CODE_OF_CONDUCT.md +0 -0
  33. {sciris-3.4.0 → sciris-3.5.1}/CONTRIBUTING.md +0 -0
  34. {sciris-3.4.0 → sciris-3.5.1}/LICENSE +0 -0
  35. {sciris-3.4.0 → sciris-3.5.1}/MANIFEST.in +0 -0
  36. {sciris-3.4.0 → sciris-3.5.1}/README.md +0 -0
  37. {sciris-3.4.0 → sciris-3.5.1}/STYLE_GUIDE.md +0 -0
  38. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/.claude-plugin/plugin.json +0 -0
  39. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/README.md +0 -0
  40. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-advanced/SKILL.md +0 -0
  41. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-arrays/SKILL.md +0 -0
  42. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-dates/SKILL.md +0 -0
  43. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-dicts/SKILL.md +0 -0
  44. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-files/SKILL.md +0 -0
  45. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-intro/SKILL.md +0 -0
  46. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-parallel/SKILL.md +0 -0
  47. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-plotting/SKILL.md +0 -0
  48. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-printing/SKILL.md +0 -0
  49. {sciris-3.4.0 → sciris-3.5.1}/claude_plugin/skills/sciris-utils/SKILL.md +0 -0
  50. {sciris-3.4.0 → sciris-3.5.1}/context7.json +0 -0
  51. {sciris-3.4.0 → sciris-3.5.1}/docs/404.qmd +0 -0
  52. {sciris-3.4.0 → sciris-3.5.1}/docs/CNAME +0 -0
  53. {sciris-3.4.0 → sciris-3.5.1}/docs/README.md +0 -0
  54. {sciris-3.4.0 → sciris-3.5.1}/docs/_extensions/machow/interlinks/objects.txt +0 -0
  55. {sciris-3.4.0 → sciris-3.5.1}/docs/_quarto.yml +0 -0
  56. {sciris-3.4.0 → sciris-3.5.1}/docs/_site/llms-full.txt +0 -0
  57. {sciris-3.4.0 → sciris-3.5.1}/docs/_site/llms.txt +0 -0
  58. {sciris-3.4.0 → sciris-3.5.1}/docs/_site/robots.txt +0 -0
  59. {sciris-3.4.0 → sciris-3.5.1}/docs/_variables.yml +0 -0
  60. {sciris-3.4.0 → sciris-3.5.1}/docs/api/index.qmd +0 -0
  61. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/favicon.ico +0 -0
  62. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/sciris-logo-dark.png +0 -0
  63. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/sciris-logo-grey.png +0 -0
  64. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/sciris-logo-large.png +0 -0
  65. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/sciris-logo.png +0 -0
  66. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/sciris-showcase-code.png +0 -0
  67. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/sciris-showcase-output.png +0 -0
  68. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/styles-dark.scss +0 -0
  69. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/styles-light.scss +0 -0
  70. {sciris-3.4.0 → sciris-3.5.1}/docs/assets/styles.css +0 -0
  71. {sciris-3.4.0 → sciris-3.5.1}/docs/check_notebooks.py +0 -0
  72. {sciris-3.4.0 → sciris-3.5.1}/docs/clean_all +0 -0
  73. {sciris-3.4.0 → sciris-3.5.1}/docs/clean_outputs.py +0 -0
  74. {sciris-3.4.0 → sciris-3.5.1}/docs/conduct.qmd +0 -0
  75. {sciris-3.4.0 → sciris-3.5.1}/docs/contributing.qmd +0 -0
  76. {sciris-3.4.0 → sciris-3.5.1}/docs/overview.qmd +0 -0
  77. {sciris-3.4.0 → sciris-3.5.1}/docs/preview +0 -0
  78. {sciris-3.4.0 → sciris-3.5.1}/docs/publish +0 -0
  79. {sciris-3.4.0 → sciris-3.5.1}/docs/quarto_utils.py +0 -0
  80. {sciris-3.4.0 → sciris-3.5.1}/docs/render +0 -0
  81. {sciris-3.4.0 → sciris-3.5.1}/docs/requirements.txt +0 -0
  82. {sciris-3.4.0 → sciris-3.5.1}/docs/sciris-citation.bib +0 -0
  83. {sciris-3.4.0 → sciris-3.5.1}/docs/style_guide.qmd +0 -0
  84. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/.jupyter_cache/__version__.txt +0 -0
  85. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/README.md +0 -0
  86. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/convert_notebooks +0 -0
  87. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/dancing_lines.gif +0 -0
  88. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-advanced.gif +0 -0
  89. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-arrays.png +0 -0
  90. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-dates.png +0 -0
  91. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-dicts.png +0 -0
  92. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-files.png +0 -0
  93. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-intro.png +0 -0
  94. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-parallel.png +0 -0
  95. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-plotting.png +0 -0
  96. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-printing.png +0 -0
  97. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/img-utils.png +0 -0
  98. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/sim1/sim.py +0 -0
  99. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/sim2/sim.py +0 -0
  100. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_advanced.qmd +0 -0
  101. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_arrays.qmd +0 -0
  102. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_dates.qmd +0 -0
  103. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_files.qmd +0 -0
  104. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_intro.qmd +0 -0
  105. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_parallel.qmd +0 -0
  106. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_plotting.qmd +0 -0
  107. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_printing.qmd +0 -0
  108. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials/tut_utils.qmd +0 -0
  109. {sciris-3.4.0 → sciris-3.5.1}/docs/tutorials.qmd +0 -0
  110. {sciris-3.4.0 → sciris-3.5.1}/docs/whatsnew.qmd +0 -0
  111. {sciris-3.4.0 → sciris-3.5.1}/pyproject.toml +0 -0
  112. {sciris-3.4.0 → sciris-3.5.1}/sciris/README.md +0 -0
  113. {sciris-3.4.0 → sciris-3.5.1}/sciris/__init__.py +0 -0
  114. {sciris-3.4.0 → sciris-3.5.1}/sciris/_extras/README.md +0 -0
  115. {sciris-3.4.0 → sciris-3.5.1}/sciris/_extras/__init__.py +0 -0
  116. {sciris-3.4.0 → sciris-3.5.1}/sciris/_extras/ansicolors.py +0 -0
  117. {sciris-3.4.0 → sciris-3.5.1}/sciris/fancy.mplstyle +0 -0
  118. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_asd.py +0 -0
  119. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_colors.py +0 -0
  120. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_dataframe.py +0 -0
  121. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_fileio.py +0 -0
  122. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_math.py +0 -0
  123. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_parallel.py +0 -0
  124. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_plotting.py +0 -0
  125. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_printing.py +0 -0
  126. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_profiling.py +0 -0
  127. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_settings.py +0 -0
  128. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_utils.py +0 -0
  129. {sciris-3.4.0 → sciris-3.5.1}/sciris/sc_versioning.py +0 -0
  130. {sciris-3.4.0 → sciris-3.5.1}/sciris/simple.mplstyle +0 -0
  131. {sciris-3.4.0 → sciris-3.5.1}/sciris.egg-info/dependency_links.txt +0 -0
  132. {sciris-3.4.0 → sciris-3.5.1}/sciris.egg-info/requires.txt +0 -0
  133. {sciris-3.4.0 → sciris-3.5.1}/sciris.egg-info/top_level.txt +0 -0
  134. {sciris-3.4.0 → sciris-3.5.1}/setup.cfg +0 -0
  135. {sciris-3.4.0 → sciris-3.5.1}/tests/.coveragerc +0 -0
  136. {sciris-3.4.0 → sciris-3.5.1}/tests/.pytest_cache/README.md +0 -0
  137. {sciris-3.4.0 → sciris-3.5.1}/tests/README.rst +0 -0
  138. {sciris-3.4.0 → sciris-3.5.1}/tests/check_coverage +0 -0
  139. {sciris-3.4.0 → sciris-3.5.1}/tests/check_style +0 -0
  140. {sciris-3.4.0 → sciris-3.5.1}/tests/files/archive_2023-04-19.zip +0 -0
  141. {sciris-3.4.0 → sciris-3.5.1}/tests/files/deadclass.obj +0 -0
  142. {sciris-3.4.0 → sciris-3.5.1}/tests/files/deadclass.py +0 -0
  143. {sciris-3.4.0 → sciris-3.5.1}/tests/files/exampledata.xlsx +0 -0
  144. {sciris-3.4.0 → sciris-3.5.1}/tests/files/examplefont.ttf +0 -0
  145. {sciris-3.4.0 → sciris-3.5.1}/tests/files/make_archive.py +0 -0
  146. {sciris-3.4.0 → sciris-3.5.1}/tests/files/make_deadclass.py +0 -0
  147. {sciris-3.4.0 → sciris-3.5.1}/tests/files/pickle_2021-01-01.obj +0 -0
  148. {sciris-3.4.0 → sciris-3.5.1}/tests/files/pickle_2022-01-01.obj +0 -0
  149. {sciris-3.4.0 → sciris-3.5.1}/tests/pytest.ini +0 -0
  150. {sciris-3.4.0 → sciris-3.5.1}/tests/regression/README.rst +0 -0
  151. {sciris-3.4.0 → sciris-3.5.1}/tests/regression/run_downstream +0 -0
  152. {sciris-3.4.0 → sciris-3.5.1}/tests/regression/run_regression +0 -0
  153. {sciris-3.4.0 → sciris-3.5.1}/tests/regression/sciris2021-01-01.yml +0 -0
  154. {sciris-3.4.0 → sciris-3.5.1}/tests/regression/sciris2022-01-01.yml +0 -0
  155. {sciris-3.4.0 → sciris-3.5.1}/tests/regression/sciris2023-01-01.yml +0 -0
  156. {sciris-3.4.0 → sciris-3.5.1}/tests/regression/setup_envs +0 -0
  157. {sciris-3.4.0 → sciris-3.5.1}/tests/requirements.txt +0 -0
  158. {sciris-3.4.0 → sciris-3.5.1}/tests/run_tests +0 -0
  159. {sciris-3.4.0 → sciris-3.5.1}/tests/sc_test_utils.py +0 -0
  160. {sciris-3.4.0 → sciris-3.5.1}/tests/showcase_sciris.py +0 -0
  161. {sciris-3.4.0 → sciris-3.5.1}/tests/showcase_vanilla.py +0 -0
  162. {sciris-3.4.0 → sciris-3.5.1}/tests/test_asd.py +0 -0
  163. {sciris-3.4.0 → sciris-3.5.1}/tests/test_colors.py +0 -0
  164. {sciris-3.4.0 → sciris-3.5.1}/tests/test_dataframe.py +0 -0
  165. {sciris-3.4.0 → sciris-3.5.1}/tests/test_fileio.py +0 -0
  166. {sciris-3.4.0 → sciris-3.5.1}/tests/test_math.py +0 -0
  167. {sciris-3.4.0 → sciris-3.5.1}/tests/test_parallel.py +0 -0
  168. {sciris-3.4.0 → sciris-3.5.1}/tests/test_plotting.py +0 -0
  169. {sciris-3.4.0 → sciris-3.5.1}/tests/test_printing.py +0 -0
  170. {sciris-3.4.0 → sciris-3.5.1}/tests/test_profiling.py +0 -0
  171. {sciris-3.4.0 → sciris-3.5.1}/tests/test_settings.py +0 -0
  172. {sciris-3.4.0 → sciris-3.5.1}/tests/test_utils.py +0 -0
  173. {sciris-3.4.0 → sciris-3.5.1}/tests/test_versioning.py +0 -0
@@ -4,6 +4,20 @@ All major updates to Sciris are documented here.
4
4
 
5
5
  By import convention, components of the Sciris library are listed beginning with `sc.`, e.g. `sc.odict()`.
6
6
 
7
+ ## Version 3.5.1 (2026-10-15)
8
+
9
+ 1. `sc.iterobj()` and `sc.search()` are about twice as fast; `sc.iterobj()` no longer descends into classes or NumPy dtypes stored as attributes (e.g. `self.dtype = np.int64`), since their attributes belong to the class rather than the object. To restore the previous behavior, pass `atomic=sc.sc_nested.atomic_with_tuple`.
10
+ 1. `sc.search()` no longer matches the root object when searching by key.
11
+ 1. `sc.yeartodate()` is about 6 times faster, and now accepts a list or array of years, returning a list of dates (about 40 ns per date).
12
+
13
+ ## Version 3.5.0 (2026-10-13)
14
+
15
+ 1. `sc.obj()` is a new, shorter name for `sc.objdict()`, e.g. `pars = sc.obj(a=1, b=2)`. It is the preferred name for new code.
16
+ 1. `sc.objdict()` is now much faster: reading a key as an attribute (`obj.a`) is about 10 times faster, setting one (`obj.a = 1`) is about 4 times faster, and looking up a method (`obj.items`) is about 3 times faster. Creating an `sc.odict()` or `sc.objdict()` is about twice as fast, and `sc.iterobj()` and `sc.search()` are faster too.
17
+ 1. `sc.dictobj()` now stores its keys in the dict itself, so it works with `json.dumps()` and other functions that expect a plain dict. Item access (`obj['a']`) is about 5 times faster. Files saved with earlier versions can still be loaded.
18
+ 1. As a guide to which to use: `sc.dictobj()` is nearly as fast as a plain `dict` (about 15–20 ns to get or set a key either way, versus 10–15 ns for a `dict`), while `sc.objdict()` takes about 40–50 ns to get a key and 200–300 ns to set one, in exchange for the extra features of `sc.odict()` (such as indexing by position) and for protecting its methods from being overwritten by keys. So use `sc.objdict()` by default, and `sc.dictobj()` or a `dict` in loops that run millions of times.
19
+ 1. For subclasses of `sc.objdict()` with properties: if a property raises an exception, that exception is now always raised (previously, a key with the same name would be returned instead); and if a property fails because an attribute is missing, the error message names the property rather than the missing attribute.
20
+
7
21
  ## Version 3.4.0 (2026-09-28)
8
22
 
9
23
  ### Major changes
@@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
14
14
  - **Run tests:** `cd tests && ./run_tests`
15
15
  - **Install dev:** `pip install -e .`
16
16
  - **Build docs:** `cd docs && ./render`
17
- - **Regenerate API index:** `cd docs && python make_api.py` (updates `docs/api.json`, `docs/llms.txt`, `docs/llms-full.txt`; run after adding, renaming, or changing the signature of any public function, or `tests/test_api.py` will fail)
17
+ - **Generate API index locally:** `cd docs && python make_api.py` (writes `docs/api.json`, `docs/llms.txt`, `docs/llms-full.txt`; these are gitignored, and are built automatically with the docs and published at e.g. https://docs.sciris.org/llms.txt)
18
18
  - **Standard import:** `import sciris as sc`
19
19
 
20
20
  ### Core Functions
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sciris
3
- Version: 3.4.0
3
+ Version: 3.5.1
4
4
  Summary: Fast, flexible tools to simplify scientific Python
5
5
  Author-email: Sciris Development Team <info@sciris.org>
6
6
  License-Expression: MIT
@@ -14,6 +14,11 @@ objects.inv
14
14
  # But keep the index.qmd file
15
15
  !/api/index.qmd
16
16
 
17
+ # Ignore the auto-generated API index (see make_api.py)
18
+ /api.json
19
+ /llms.txt
20
+ /llms-full.txt
21
+
17
22
  **/*.quarto_ipynb
18
23
 
19
24
  /.quarto/
@@ -44,4 +44,4 @@ The entire Sciris API — every public function and class, with its signature, s
44
44
  - [llms-full.txt](llms-full.txt): the same, plus a usage example for each function (~90 kB)
45
45
  - [api.json](api.json): the same data as structured JSON, for programmatic use
46
46
 
47
- All three files are generated from the source (`cd docs && python make_api.py`) and are verified in CI, so they never drift from the current version of Sciris.
47
+ All three files are generated from the source each time the docs are built, so they never drift from the current version of Sciris. To generate them locally, run `cd docs && python make_api.py`.
@@ -13,22 +13,18 @@ published with the docs (e.g. <https://docs.sciris.org/llms.txt>):
13
13
 
14
14
  This is a docs build tool: nothing here is part of the Sciris package itself.
15
15
 
16
- To regenerate the artifacts::
16
+ The artifacts are not committed to the repository: they are generated when the
17
+ docs are built. To generate them manually::
17
18
 
18
19
  cd docs && python make_api.py
19
-
20
- To check that they are up to date (used by the test suite, hence CI)::
21
-
22
- cd docs && python make_api.py --check
23
20
  """
24
21
 
25
22
  import re
26
- import sys
27
23
  import inspect
28
24
  import textwrap
29
25
  import sciris as sc
30
26
 
31
- __all__ = ['make_index', 'load', 'write', 'check']
27
+ __all__ = ['make_index', 'load', 'write']
32
28
 
33
29
  thisdir = sc.thispath(__file__)
34
30
  jsonfile = thisdir / 'api.json'
@@ -314,44 +310,5 @@ def write(verbose=True):
314
310
  return written
315
311
 
316
312
 
317
- def check(verbose=True):
318
- """
319
- Check whether the generated artifacts match the current API.
320
-
321
- Args:
322
- verbose (bool): whether to print which files are out of date
323
-
324
- Returns:
325
- The list of files that are out of date (empty if everything matches).
326
- """
327
- index = make_index()
328
- stale = []
329
-
330
- def compare(path, expected):
331
- """ Compare a file to what it should contain, treating a missing file as stale """
332
- if not path.exists():
333
- return 'missing'
334
- actual = sc.loadjson(path) if path.suffix == '.json' else sc.loadtext(path)
335
- return None if actual == expected else 'out of date'
336
-
337
- for path,expected in [(jsonfile, index),
338
- (llmsfile, make_llms_txt(index, examples=False)),
339
- (llmsfullfile, make_llms_txt(index, examples=True))]:
340
- reason = compare(path, expected)
341
- if reason:
342
- stale.append(path)
343
- if verbose:
344
- print(f' {path.name} is {reason}')
345
- if verbose and not stale:
346
- print(f'All API artifacts are up to date ({index["n_entries"]} entries, v{index["version"]})')
347
- return stale
348
-
349
-
350
313
  if __name__ == '__main__':
351
- if '--check' in sys.argv:
352
- stale = check()
353
- if stale:
354
- print('Run "python make_api.py" to regenerate.')
355
- sys.exit(1)
356
- else:
357
- write()
314
+ write()
@@ -101,7 +101,19 @@ print(f'Checking {ob.key1 = }')
101
101
  print(f'Checking {ob["key1"] = }') # We need to use double-quotes inside since single quotes are taken!
102
102
  ```
103
103
 
104
- In most cases, you probably want to use `objdict`s rather than `odict`s just to have the extra flexibility. Why would you ever use an `odict` over an `objdict`? Mostly just because there's small but nonzero overhead in doing the extra attribute checking: `odict` is faster (faster than even `collections.OrderedDict`, though slower than a plain `dict`). The differences are tiny (literally nanoseconds) so won't matter unless you're doing millions of operations. But if you're reading this, chances are high that you _do_ sometimes need to do millions of dict operations.
104
+ In most cases, you probably want to use `objdict`s rather than `odict`s just to have the extra flexibility. Since they're so common, `sc.obj()` is a shorter name for `sc.objdict()`.
105
+
106
+ ### Performance
107
+
108
+ All this flexibility has a cost, although a small one. If you only need attribute access and not the other `odict` features (such as indexing by position), `sc.dictobj()` is a lighter-weight alternative that is nearly as fast as a plain `dict`. Approximate times per operation, in nanoseconds:
109
+
110
+ | | `obj['a']` | `obj.a` | `obj['a'] = 1` | `obj.a = 1` | Create |
111
+ |---|---|---|---|---|---|
112
+ | `dict` | 10 | n/a | 15 | n/a | 50 |
113
+ | `sc.dictobj()` | 20 | 15 | 15 | 20 | 450 |
114
+ | `sc.objdict()` | 40 | 50 | 190 | 330 | 600 |
115
+
116
+ These differences won't matter unless you're doing millions of operations. But if you're reading this, chances are high that you _do_ sometimes need to do millions of dict operations, in which case use `sc.dictobj()` or a plain `dict` inside the loop. The other difference is that an `sc.objdict()` protects its methods (`obj.keys = 3` raises an error, and `obj['keys'] = 3` leaves `obj.keys()` working), whereas in an `sc.dictobj()` a key replaces a method of the same name.
105
117
 
106
118
  ## Dataframes
107
119
 
@@ -662,29 +662,41 @@ def yeartodate(year, as_date=True, **kwargs):
662
662
  Convert a decimal year to a date
663
663
 
664
664
  Args:
665
- year (int, float): The numerical year to convert to a DateTime
666
- as_date (bool): If True (default), return an `sc.date` object, otherwise return a string
665
+ year (int, float, list, array): The numerical year (or years) to convert to a date
666
+ as_date (bool): If True (default), return a `datetime.date` object, otherwise return a string
667
667
 
668
668
  Returns:
669
- An `sc.date` object (default) or string, depending on the `as_date` argument
669
+ A `datetime.date` object (default) or string, depending on the `as_date` argument; a list of these if a list or array of years is supplied
670
670
 
671
- **Example**:
671
+ **Examples**:
672
672
 
673
673
  ```python
674
674
  sc.yeartodate(2010.5) # Returns datetime.date(2010, 7, 2)
675
+ sc.yeartodate(np.arange(2020, 2021, 1/52)) # Returns a list of weekly dates
675
676
  ```
676
677
 
677
678
  - *New in version 3.2.1.*
679
+ - *New in version 3.5.1:* faster; list and array inputs
678
680
  """
679
681
  as_date = kwargs.pop('asdate', as_date) # Handle with or without underscore
680
- full_years = int(year)
681
- remainder = year - full_years
682
- year_days = _get_year_length(full_years).days
683
- days = int(np.round(remainder*year_days))
684
- base = dt.date(year=full_years, month=1, day=1)
685
- out = datedelta(base, days=days)
686
- if not as_date:
687
- out = str(out)
682
+
683
+ if isinstance(year, (list, tuple, np.ndarray)): # Handle multiple years
684
+ years = np.asarray(year, dtype=float)
685
+ full_years = years.astype(int) # Truncate, like int()
686
+ starts = (full_years - 1970).astype('datetime64[Y]') # Start of each year
687
+ year_days = ((starts + 1).astype('datetime64[D]') - starts.astype('datetime64[D]')).astype(int)
688
+ days = np.round((years - full_years)*year_days).astype(int)
689
+ out = (starts.astype('datetime64[D]') + days).tolist() # Convert to a list of datetime.date objects
690
+ if not as_date:
691
+ out = [str(d) for d in out]
692
+ else:
693
+ full_years = int(year)
694
+ remainder = year - full_years
695
+ year_days = _get_year_length(full_years).days
696
+ days = int(np.round(remainder*year_days))
697
+ out = dt.date(year=full_years, month=1, day=1) + dt.timedelta(days=days)
698
+ if not as_date:
699
+ out = str(out)
688
700
  return out
689
701
 
690
702
 
@@ -20,6 +20,7 @@ from .sc_utils import _not_given, _is_given # Allows searching for actual None v
20
20
  # Define objects for which it doesn't make sense to descend further -- used here and sc.equal()
21
21
  atomic_classes = [np.ndarray, pd.Series, pd.DataFrame, pd.core.indexes.base.Index]
22
22
  atomic_with_tuple = atomic_classes + [tuple]
23
+ atomic_types = [type, np.dtype] # Classes and dtypes are also not descended into by sc.iterobj(), but are compared normally by sc.equal()
23
24
 
24
25
 
25
26
  ##############################################################################
@@ -402,7 +403,7 @@ class IterObj:
402
403
  # Attributes with initialization required
403
404
  self._trace = []
404
405
  self._memo = co.defaultdict(int)
405
- self.output = sc.objdict()
406
+ self.output = {} # Converted to an objdict once the iteration is complete, since it's much faster to populate a dict
406
407
  if self.func is None: # If no function provided, define a function that just returns the contents of the current node
407
408
  self.func = lambda obj: obj
408
409
 
@@ -411,15 +412,15 @@ class IterObj:
411
412
  atomic_list = sc.tolist(self.atomic)
412
413
  if 'default' in atomic_list: # Handle objects to not descend into
413
414
  atomic_list.remove('default')
414
- base_atomic = atomic_with_tuple
415
+ base_atomic = atomic_with_tuple + atomic_types
415
416
  if 'default-tuple' in atomic_list:
416
417
  atomic_list.remove('default-tuple')
417
- base_atomic = atomic_classes
418
+ base_atomic = atomic_classes + atomic_types
418
419
  self.atomic = tuple(base_atomic + atomic_list)
419
420
 
420
421
  # Handle objects to skip
421
422
  if isinstance(skip, dict):
422
- skip = sc.dcp(skip)
423
+ skip = dict(skip) # Shallow copy since the values are only read
423
424
  skip_keys = sc.tolist(skip.pop('keys', None))
424
425
  skip_ids = sc.tolist(skip.pop('ids', None))
425
426
  skip_subclasses = sc.tolist(skip.pop('subclasses', None))
@@ -481,9 +482,10 @@ class IterObj:
481
482
  print(space*len(self._trace) + string)
482
483
  return
483
484
 
484
- def iteritems(self, parent, trace):
485
+ def iteritems(self, parent, trace, itertype=None):
485
486
  """ Return an iterator over items in this object """
486
- itertype = self.check_iter_type(parent)
487
+ if itertype is None:
488
+ itertype = self.check_iter_type(parent)
487
489
  out = None
488
490
  if self.custom_iter:
489
491
  out = self.custom_iter(parent)
@@ -505,9 +507,7 @@ class IterObj:
505
507
  out = [] # Return nothing if doesn't have __dict__.items() or __slots__, e.g. a weird wrapped function
506
508
  else:
507
509
  out = [] # Return nothing if not recognized
508
- out = list(out)
509
- for i in range(len(out)):
510
- out[i] = [parent, trace, *list(out[i])] # Prepend parent and trace to the arguments
510
+ out = [(parent, trace, k, v) for k,v in out] # Prepend parent and trace to the arguments
511
511
  return out
512
512
 
513
513
  def getitem(self, key, parent):
@@ -542,12 +542,14 @@ class IterObj:
542
542
  """ Shortcut to check_iter_type() """
543
543
  return check_iter_type(obj, known=self.atomic, custom=self.custom_type)
544
544
 
545
- def check_proceed(self, key, subobj, newid):
545
+ def check_proceed(self, key, subobj, newid, itertype=None):
546
546
  """ Check if we should process the object, and if so, whether to descend into it """
547
547
 
548
548
  # If we've already parsed this (iterable) object, skip it, or if aliases=True, process it but don't descend into it again
549
549
  in_memo = (newid in self._memo) and (self._memo[newid] > self.recursion)
550
- repeat = in_memo and self.check_iter_type(subobj)
550
+ if in_memo and itertype is None:
551
+ itertype = self.check_iter_type(subobj)
552
+ repeat = in_memo and bool(itertype)
551
553
  memo_skip = repeat and not self.aliases
552
554
  descend = not repeat
553
555
  if repeat and self.aliases:
@@ -560,22 +562,23 @@ class IterObj:
560
562
  instance_skip = isinstance(subobj, self._skip_instances)
561
563
 
562
564
  # Finalize
563
- skips = [memo_skip, key_skip, id_skip, subclass_skip, instance_skip]
564
- proceed = not any(skips)
565
+ proceed = not (memo_skip or key_skip or id_skip or subclass_skip or instance_skip)
565
566
 
566
567
  if not proceed and self.verbose: # Just for debugging
568
+ skips = [memo_skip, key_skip, id_skip, subclass_skip, instance_skip]
567
569
  labels = ['memo', 'key', 'id', 'subclass', 'instance']
568
570
  pairs = [f'{label}_skip=True' for label,skip in zip(labels, skips) if skip]
569
571
  self.indent(f'Skipping "{key}" because {sc.strjoin(pairs)}')
570
572
 
571
573
  return proceed, descend
572
574
 
573
- def process_obj(self, parent, trace, key, subobj, newid):
575
+ def process_obj(self, parent, trace, key, subobj, newid, itertype=None):
574
576
  """ Process a single object, returning its trace and its new value """
575
577
  self._memo[newid] += 1
576
578
  trace = trace + [key]
577
- subitertype = self.check_iter_type(subobj)
578
- self.indent(f'{len(self)} Trace {trace} | Type "{str(subitertype)}" | {type(subobj)}')
579
+ subitertype = self.check_iter_type(subobj) if itertype is None else itertype
580
+ if self.verbose: # Check here to avoid formatting the string
581
+ self.indent(f'{len(self)} Trace {trace} | Type "{str(subitertype)}" | {type(subobj)}')
579
582
  newobj = subobj
580
583
  if not (self.leaf and subitertype):
581
584
  newobj = self.func(subobj, **self.func_kw)
@@ -604,11 +607,15 @@ class IterObj:
604
607
  while queue:
605
608
  parent,trace,key,subobj = queue.popleft()
606
609
  newid = id(subobj)
607
- proceed, descend = self.check_proceed(key, subobj, newid)
610
+ itertype = self.check_iter_type(subobj) # Calculate once, since it's needed several times
611
+ proceed, descend = self.check_proceed(key, subobj, newid, itertype=itertype)
608
612
  if proceed:
609
- newtrace, newobj = self.process_obj(parent, trace, key, subobj, newid) # Process the object
613
+ newtrace, newobj = self.process_obj(parent, trace, key, subobj, newid, itertype=itertype) # Process the object
610
614
  if descend: # If modifying in place, descend into the new object, since that's what's in the parent now
611
- newitems = self.iteritems(newobj if self.inplace else subobj, newtrace)
615
+ if newobj is subobj or not self.inplace:
616
+ newitems = self.iteritems(subobj, newtrace, itertype=itertype)
617
+ else:
618
+ newitems = self.iteritems(newobj, newtrace)
612
619
  if self.depthfirst:
613
620
  queue.extendleft(reversed(newitems)) # extendleft() swaps order, so swap back
614
621
  else:
@@ -620,6 +627,7 @@ class IterObj:
620
627
  return self.func(self.obj, **self.func_kw) # Set at the root level
621
628
  return self.obj
622
629
  else:
630
+ self.output = sc.objdict(self.output)
623
631
  return self.output
624
632
 
625
633
  def flatten_traces(self, sep='_', inplace=True):
@@ -675,7 +683,7 @@ def iterobj(obj, func=None, inplace=False, copy=False, leaf=False, recursion=0,
675
683
  leaf (bool): whether to apply the function only to leaf nodes of the object
676
684
  recursion (int): number of recursive steps to allow, i.e. parsing the same objects multiple times (default 0)
677
685
  depthfirst (bool): whether to parse the object depth-first (default) or breadth-first
678
- atomic (list): a list of known classes to treat as atomic (do not descend into); if 'default', use defaults (e.g. tuple, `np.array`, `pd.DataFrame`); if 'default-tuple', use defaults except for tuples
686
+ atomic (list): a list of known classes to treat as atomic (do not descend into); if 'default', use defaults (e.g. tuple, `np.array`, `pd.DataFrame`, classes, and NumPy dtypes); if 'default-tuple', use defaults except for tuples
679
687
  skip (list/dict): a list of objects to skip over entirely; can also be a dict with "keys", "ids", "subclasses", and/or "instances", which skip each of those
680
688
  rootkey (str): the key to list as the root of the object (default `'root'`)
681
689
  verbose (bool): whether to print progress
@@ -716,6 +724,7 @@ def iterobj(obj, func=None, inplace=False, copy=False, leaf=False, recursion=0,
716
724
  - *New in version 3.1.5:* "recursion" argument; better handling of atomic classes
717
725
  - *New in version 3.1.6:* "skip", "depthfirst", "to_df", and "flatten" arguments
718
726
  - *New in version 3.4.0:* "aliases" argument
727
+ - *New in version 3.5.1:* classes and NumPy dtypes are atomic by default
719
728
  """
720
729
  # Create the object
721
730
  io = IterObj(obj=obj, func=func, inplace=inplace, copy=copy, leaf=leaf, recursion=recursion, aliases=aliases, depthfirst=depthfirst,
@@ -962,23 +971,15 @@ def search(obj, query=_not_given, key=_not_given, value=_not_given, type=_not_gi
962
971
  flatten = kwargs.pop('flatten', False) # Don't flatten because that will disrupt the matching
963
972
  tree = iterobj(obj, **kwargs) # For key matching
964
973
 
965
- # Do the matching
966
- matches = []
967
-
968
- # Match keys
969
- if _is_given(key):
970
- for k in tree.keys():
971
- if check_match(k[-1], key): # Only want the last key of the trace
972
- matches.append(k)
973
-
974
- # Match values (including types)
975
- if _is_given(value):
976
- for k,v in tree.items():
977
- if check_match(v, value):
978
- matches.append(k)
979
-
980
- # Reassemble dict to maintain order
981
- out = sc.objdict({k:v for k,v in tree.items() if k in matches})
974
+ # Do the matching, keeping the order of the tree
975
+ match_key = _is_given(key)
976
+ match_value = _is_given(value)
977
+ out = {}
978
+ for k,v in tree.items():
979
+ is_root = not isinstance(k, tuple) # The root has no key to match
980
+ if (match_key and not is_root and check_match(k[-1], key)) or (match_value and check_match(v, value)): # Only want the last key of the trace
981
+ out[k] = v
982
+ out = sc.objdict(out)
982
983
 
983
984
  if flatten:
984
985
  out = flatten_traces(out)
@@ -8,18 +8,19 @@ Highlights:
8
8
  import re
9
9
  import sys
10
10
  import json
11
+ import copyreg
11
12
  import numpy as np
12
13
  import collections as co
13
14
  import sciris as sc
14
15
  from .sc_utils import _not_given # Imported directly since it's needed at definition time
15
16
 
16
17
  # Restrict imports to user-facing modules
17
- __all__ = ['ddict', 'counter', 'odict', 'objdict', 'dictobj', 'asobj', 'argparse']
18
+ __all__ = ['ddict', 'counter', 'odict', 'objdict', 'obj', 'dictobj', 'asobj', 'argparse']
18
19
 
19
20
  ddict = co.defaultdict # Define alias
20
21
 
21
22
  # Critical attributes that should be resolved immediately, not via any special handling
22
- _fundamental_attrs = ['__class__', '__reduce__', '__reduce_ex__', '__getstate__', '__setstate__', '__copy__','__deepcopy__']
23
+ _fundamental_attrs = frozenset(['__class__', '__reduce__', '__reduce_ex__', '__getstate__', '__setstate__', '__copy__','__deepcopy__'])
23
24
 
24
25
 
25
26
  class counter(co.Counter):
@@ -140,6 +141,8 @@ class odict(dict):
140
141
  - *New in version 3.1.1:* allow steps in slices; `copy()` now behaves as a standard dict
141
142
  """
142
143
 
144
+ _stale = True # The keys are only cached (as a list, for integer lookup) when first needed
145
+
143
146
  def __init__(self, *args, defaultdict=None, **kwargs):
144
147
  """ Combine the init properties of both OrderedDict and defaultdict """
145
148
 
@@ -154,7 +157,6 @@ class odict(dict):
154
157
  raise TypeError(errormsg)
155
158
  self._setattr('_defaultdict', defaultdict) # Use object.__setattr__() since setattr() is overridden by sc.objdict()
156
159
 
157
- self._cache_keys()
158
160
  return
159
161
 
160
162
 
@@ -240,10 +242,10 @@ class odict(dict):
240
242
  def __setitem__(self, key, value):
241
243
  """ Allows setitem to support strings, integers, slices, lists, or arrays """
242
244
 
243
- self._setattr('_stale', True) # Flag to refresh the cached keys
245
+ object.__setattr__(self, '_stale', True) # Flag to refresh the cached keys
244
246
 
245
247
  if isinstance(key, (str,tuple)):
246
- self._setitem(key, value)
248
+ dict.__setitem__(self, key, value)
247
249
 
248
250
  elif isinstance(key, sc._numtype): # Convert automatically from float...dangerous?
249
251
  thiskey = key if dict.__contains__(self, key) else self._ikey(key) # As with getitem, an existing key takes precedence
@@ -1260,6 +1262,8 @@ class objdict(odict):
1260
1262
  instead act on dict keys (e.g. `obj['x'] = 3`). If you want to actually get/set
1261
1263
  an attribute, use `obj.getattribute()`/`obj.setattribute()`.
1262
1264
 
1265
+ `sc.obj()` is an alias for `sc.objdict()`.
1266
+
1263
1267
  For a lighter-weight example (an object that acts like a dict), see `sc.dictobj()`.
1264
1268
 
1265
1269
  **Examples**:
@@ -1281,6 +1285,8 @@ class objdict(odict):
1281
1285
 
1282
1286
  For a lighter-weight equivalent (based on `dict` instead of `odict`), see
1283
1287
  `sc.dictobj()`.
1288
+
1289
+ - *New in version 3.5.0:* `sc.obj()` alias; much faster attribute access
1284
1290
  """
1285
1291
 
1286
1292
  def __init__(self, *args, **kwargs):
@@ -1298,40 +1304,41 @@ class objdict(odict):
1298
1304
  return odict.__repr__(self, quote='', numsep='.', classname=self.__class__.__name__, *args, **kwargs)
1299
1305
 
1300
1306
 
1301
- def __getattribute__(self, attr):
1302
- """ Handle as attributes first, then as dict keys """
1303
- # Pickle/copy protocol: must resolve from class, not as dict keys
1307
+ def __getattr__(self, attr):
1308
+ """ Only called if not found as an attribute, so get it as a dict key """
1309
+ if attr not in _fundamental_attrs: # Pickle/copy protocol: must resolve from class, not as dict keys
1310
+ try: # The usual case: it's a key
1311
+ return dict.__getitem__(self, attr)
1312
+ except KeyError:
1313
+ pass
1314
+ errormsg = f"'{type(self).__name__}' object has no attribute '{attr}'"
1315
+ exception = AttributeError(errormsg, name=attr, obj=self)
1304
1316
  if attr in _fundamental_attrs:
1305
- return object.__getattribute__(self, attr)
1306
- else:
1307
- try: # First, try to get the attribute as an attribute
1308
- return object.__getattribute__(self, attr)
1309
- except Exception as E: # If that fails, try to get it as a dict item, but pass along the original exception
1310
- return self.__getitem__(attr, exception=E)
1317
+ raise exception
1318
+ return self.__getitem__(attr, exception=exception) # Handle defaults, or else raise the exception
1311
1319
 
1312
1320
 
1313
1321
  def __setattr__(self, name, value):
1314
1322
  """ Set key in dict, not attribute! """
1315
1323
  if name[:2] == '__': # If it starts with a double underscore, it's almost certainly an attribute, not a key
1316
1324
  return object.__setattr__(self, name, value)
1325
+ elif name in self.__dict__ or hasattr(type(self), name):
1326
+ errormsg = f'"{name}" exists as an attribute, so cannot be set as key; use setattribute() instead'
1327
+ raise KeyError(errormsg)
1317
1328
  else:
1318
- try:
1319
- object.__getattribute__(self, name) # Try retrieving this as an attribute, expect AttributeError...
1320
- errormsg = f'"{name}" exists as an attribute, so cannot be set as key; use setattribute() instead'
1321
- raise KeyError(errormsg)
1322
- except AttributeError:
1323
- return self.__setitem__(name, value) # If so, simply return
1329
+ return self.__setitem__(name, value)
1324
1330
 
1325
1331
 
1326
1332
  def __getitem__(self, attr, exception=None):
1327
1333
  """ Handle dict keys, including nested defaultdict logic """
1328
1334
  try: # Try retrieving normally
1335
+ return dict.__getitem__(self, attr)
1336
+ except Exception:
1337
+ pass
1338
+ try: # Then try integers, slices, etc.
1329
1339
  return odict.__getitem__(self, attr, allow_default=False) # Do not allow odict to handle default
1330
1340
  except Exception as E2: # If that fails, raise the original exception
1331
- try: # Handle defaultdict behavior by first checking if it exists
1332
- _defaultdict = object.__getattribute__(self, '_defaultdict')
1333
- except:
1334
- _defaultdict = None
1341
+ _defaultdict = self.__dict__.get('_defaultdict') # Handle defaultdict behavior by first checking if it exists
1335
1342
  if _defaultdict is not None: # If it does, use it, then get the key again
1336
1343
  if _defaultdict == 'nested':
1337
1344
  return self._new(defaultdict='nested', _nested_parent=self, _nested_attr=attr) # Create a recursive object with links back to the ultimate parent
@@ -1349,14 +1356,9 @@ class objdict(odict):
1349
1356
  def __setitem__(self, name, value):
1350
1357
  """ Set as a dict item """
1351
1358
  odict.__setitem__(self, name, value)
1352
- try:
1353
- p = object.__getattribute__(self, '_nested_parent')
1354
- key = object.__getattribute__(self, '_nested_attr')
1355
- except AttributeError:
1356
- p = None
1357
- key = None
1358
- if p is not None:
1359
- p[key] = self
1359
+ attrs = self.__dict__
1360
+ if '_nested_parent' in attrs:
1361
+ attrs['_nested_parent'][attrs['_nested_attr']] = self
1360
1362
  object.__delattr__(self, '_nested_parent')
1361
1363
  object.__delattr__(self, '_nested_attr')
1362
1364
  return
@@ -1389,6 +1391,9 @@ class objdict(odict):
1389
1391
  return object.__delattr__(self, name)
1390
1392
 
1391
1393
 
1394
+ obj = objdict # Define alias
1395
+
1396
+
1392
1397
  class dictobj(dict):
1393
1398
  """
1394
1399
  Lightweight class to create an object that can also act like a dictionary.
@@ -1403,35 +1408,46 @@ class dictobj(dict):
1403
1408
  ```
1404
1409
  For a more powerful alternative, see `sc.objdict()`.
1405
1410
 
1406
- **Note**: because `dictobj` is halfway between a dict and an object, it can't
1407
- be automatically converted to a JSON (but will fail silently). Use `to_json()`
1408
- instead.
1411
+ The keys and the attributes are the same thing (the dict is its own `__dict__`),
1412
+ so it is as fast as a plain object for attributes and nearly as fast as a plain
1413
+ dict for items. This also means that keys can overwrite methods (e.g. `obj.items = 5`).
1409
1414
 
1410
1415
  - *New in version 1.3.0.*
1411
1416
  - *New in version 1.3.1:* inherit from dict
1412
1417
  - *New in version 2.0.0:* allow positional arguments
1413
1418
  - *New in version 3.0.0:* "fromkeys" now a class method; `to_json()` method
1414
1419
  - *New in version 3.1.6:* "copy" returns another dictobj
1420
+ - *New in version 3.5.0:* keys are stored in the dict itself, so e.g. `json.dumps()` works; faster
1415
1421
  """
1416
1422
 
1423
+ def __new__(cls, *args, **kwargs):
1424
+ self = dict.__new__(cls)
1425
+ self.__dict__ = self # Use the dict itself to store the attributes
1426
+ return self
1427
+
1417
1428
  def __init__(self, *args, **kwargs):
1418
- kwargs = sc.mergedicts(*args, kwargs)
1419
- for k,v in kwargs.items():
1420
- self.__dict__[k] = v
1429
+ for arg in args:
1430
+ if arg is not None:
1431
+ self.update(arg)
1432
+ self.update(kwargs)
1421
1433
  return
1422
1434
 
1423
1435
  def __repr__(self):
1424
- output = 'dictobj(' + self.__dict__.__repr__() + ')'
1436
+ output = 'dictobj(' + dict.__repr__(self) + ')'
1425
1437
  return output
1426
1438
 
1427
- def to_json(self):
1428
- """ Export the dictobj to JSON (NB: regular `json.dumps()` does not work) """
1429
- return json.dumps(self.__dict__)
1439
+ def __reduce__(self):
1440
+ """ Pickle and copy the contents once, rather than as both items and attributes """
1441
+ return (copyreg.__newobj__, (self.__class__,), dict(self))
1430
1442
 
1431
- @classmethod
1432
- def fromkeys(cls, *args, **kwargs):
1433
- """ Create a new dictobj from keys """
1434
- return cls(dict.fromkeys(*args, **kwargs))
1443
+ def __setstate__(self, state):
1444
+ """ Restore from a pickle or copy, including pickles from before version 3.5.0 """
1445
+ self.update(state)
1446
+ return
1447
+
1448
+ def to_json(self):
1449
+ """ Export the dictobj to JSON """
1450
+ return json.dumps(self)
1435
1451
 
1436
1452
  def copy(self):
1437
1453
  """ Create a shallow copy """
@@ -1439,37 +1455,15 @@ class dictobj(dict):
1439
1455
  new.update(self)
1440
1456
  return new
1441
1457
 
1442
- # Copy other default dictionary methods
1443
- def __getitem__( self, *args, **kwargs): return self.__dict__.__getitem__( *args, **kwargs)
1444
- def __setitem__( self, *args, **kwargs): return self.__dict__.__setitem__( *args, **kwargs)
1445
- def __contains__(self, *args, **kwargs): return self.__dict__.__contains__(*args, **kwargs)
1446
- def __len__( self, *args, **kwargs): return self.__dict__.__len__( *args, **kwargs)
1447
- def __iter__( self, *args, **kwargs): return self.__dict__.__iter__( *args, **kwargs)
1448
- def clear( self, *args, **kwargs): return self.__dict__.clear( *args, **kwargs)
1449
- def get( self, *args, **kwargs): return self.__dict__.get( *args, **kwargs)
1450
- def items( self, *args, **kwargs): return self.__dict__.items( *args, **kwargs)
1451
- def keys( self, *args, **kwargs): return self.__dict__.keys( *args, **kwargs)
1452
- def pop( self, *args, **kwargs): return self.__dict__.pop( *args, **kwargs)
1453
- def popitem( self, *args, **kwargs): return self.__dict__.popitem( *args, **kwargs)
1454
- def setdefault( self, *args, **kwargs): return self.__dict__.setdefault( *args, **kwargs)
1455
- def update( self, *args, **kwargs): return self.__dict__.update( *args, **kwargs)
1456
- def values( self, *args, **kwargs): return self.__dict__.values( *args, **kwargs)
1457
- def __reversed__(self, *args, **kwargs): return self.__dict__.__reversed__(*args, **kwargs)
1458
-
1459
- # The dict storage itself is empty, so comparisons and merges must also use __dict__
1460
- def __eq__(self, other): return self.__dict__ == (other.__dict__ if isinstance(other, dictobj) else other)
1461
- def __ne__(self, other): return not self.__eq__(other)
1462
1458
  def __or__(self, other):
1463
1459
  new = self.copy()
1464
1460
  new.update(other)
1465
1461
  return new
1462
+
1466
1463
  def __ror__(self, other):
1467
1464
  new = self.__class__(other)
1468
1465
  new.update(self)
1469
1466
  return new
1470
- def __ior__(self, other):
1471
- self.update(other)
1472
- return self
1473
1467
 
1474
1468
 
1475
1469
  def asobj(obj, strict=True):