sciris 3.5.0__tar.gz → 3.5.2__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 (170) hide show
  1. {sciris-3.5.0 → sciris-3.5.2}/CHANGELOG.md +12 -0
  2. {sciris-3.5.0/sciris.egg-info → sciris-3.5.2}/PKG-INFO +1 -1
  3. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_datetime.py +24 -12
  4. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_nested.py +36 -36
  5. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_utils.py +56 -16
  6. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_version.py +2 -2
  7. {sciris-3.5.0 → sciris-3.5.2/sciris.egg-info}/PKG-INFO +1 -1
  8. {sciris-3.5.0 → sciris-3.5.2}/sciris.egg-info/SOURCES.txt +0 -1
  9. {sciris-3.5.0 → sciris-3.5.2}/tests/pytest.ini +1 -0
  10. {sciris-3.5.0 → sciris-3.5.2}/tests/test_datetime.py +7 -0
  11. {sciris-3.5.0 → sciris-3.5.2}/tests/test_nested.py +2 -0
  12. {sciris-3.5.0 → sciris-3.5.2}/tests/test_utils.py +22 -2
  13. sciris-3.5.0/objdict_performance.md +0 -89
  14. {sciris-3.5.0 → sciris-3.5.2}/.claude-plugin/marketplace.json +0 -0
  15. {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/publish_docs.yaml +0 -0
  16. {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/pypi_release.yaml +0 -0
  17. {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/schedule_tests.yaml +0 -0
  18. {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/test_docs.yaml +0 -0
  19. {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/test_downstream.yaml +0 -0
  20. {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/test_sciris.yaml +0 -0
  21. {sciris-3.5.0 → sciris-3.5.2}/.gitignore +0 -0
  22. {sciris-3.5.0 → sciris-3.5.2}/.pylintrc +0 -0
  23. {sciris-3.5.0 → sciris-3.5.2}/.pytest_cache/README.md +0 -0
  24. {sciris-3.5.0 → sciris-3.5.2}/CLAUDE.md +0 -0
  25. {sciris-3.5.0 → sciris-3.5.2}/CODE_OF_CONDUCT.md +0 -0
  26. {sciris-3.5.0 → sciris-3.5.2}/CONTRIBUTING.md +0 -0
  27. {sciris-3.5.0 → sciris-3.5.2}/LICENSE +0 -0
  28. {sciris-3.5.0 → sciris-3.5.2}/MANIFEST.in +0 -0
  29. {sciris-3.5.0 → sciris-3.5.2}/README.md +0 -0
  30. {sciris-3.5.0 → sciris-3.5.2}/STYLE_GUIDE.md +0 -0
  31. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/.claude-plugin/plugin.json +0 -0
  32. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/README.md +0 -0
  33. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-advanced/SKILL.md +0 -0
  34. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-arrays/SKILL.md +0 -0
  35. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-dates/SKILL.md +0 -0
  36. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-dicts/SKILL.md +0 -0
  37. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-files/SKILL.md +0 -0
  38. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-intro/SKILL.md +0 -0
  39. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-parallel/SKILL.md +0 -0
  40. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-plotting/SKILL.md +0 -0
  41. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-printing/SKILL.md +0 -0
  42. {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-utils/SKILL.md +0 -0
  43. {sciris-3.5.0 → sciris-3.5.2}/context7.json +0 -0
  44. {sciris-3.5.0 → sciris-3.5.2}/docs/.gitignore +0 -0
  45. {sciris-3.5.0 → sciris-3.5.2}/docs/404.qmd +0 -0
  46. {sciris-3.5.0 → sciris-3.5.2}/docs/CNAME +0 -0
  47. {sciris-3.5.0 → sciris-3.5.2}/docs/README.md +0 -0
  48. {sciris-3.5.0 → sciris-3.5.2}/docs/_extensions/machow/interlinks/objects.txt +0 -0
  49. {sciris-3.5.0 → sciris-3.5.2}/docs/_quarto.yml +0 -0
  50. {sciris-3.5.0 → sciris-3.5.2}/docs/_site/llms-full.txt +0 -0
  51. {sciris-3.5.0 → sciris-3.5.2}/docs/_site/llms.txt +0 -0
  52. {sciris-3.5.0 → sciris-3.5.2}/docs/_site/robots.txt +0 -0
  53. {sciris-3.5.0 → sciris-3.5.2}/docs/_variables.yml +0 -0
  54. {sciris-3.5.0 → sciris-3.5.2}/docs/api/index.qmd +0 -0
  55. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/favicon.ico +0 -0
  56. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-logo-dark.png +0 -0
  57. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-logo-grey.png +0 -0
  58. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-logo-large.png +0 -0
  59. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-logo.png +0 -0
  60. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-showcase-code.png +0 -0
  61. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-showcase-output.png +0 -0
  62. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/styles-dark.scss +0 -0
  63. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/styles-light.scss +0 -0
  64. {sciris-3.5.0 → sciris-3.5.2}/docs/assets/styles.css +0 -0
  65. {sciris-3.5.0 → sciris-3.5.2}/docs/check_notebooks.py +0 -0
  66. {sciris-3.5.0 → sciris-3.5.2}/docs/clean_all +0 -0
  67. {sciris-3.5.0 → sciris-3.5.2}/docs/clean_outputs.py +0 -0
  68. {sciris-3.5.0 → sciris-3.5.2}/docs/conduct.qmd +0 -0
  69. {sciris-3.5.0 → sciris-3.5.2}/docs/contributing.qmd +0 -0
  70. {sciris-3.5.0 → sciris-3.5.2}/docs/index.qmd +0 -0
  71. {sciris-3.5.0 → sciris-3.5.2}/docs/make_api.py +0 -0
  72. {sciris-3.5.0 → sciris-3.5.2}/docs/overview.qmd +0 -0
  73. {sciris-3.5.0 → sciris-3.5.2}/docs/preview +0 -0
  74. {sciris-3.5.0 → sciris-3.5.2}/docs/publish +0 -0
  75. {sciris-3.5.0 → sciris-3.5.2}/docs/quarto_utils.py +0 -0
  76. {sciris-3.5.0 → sciris-3.5.2}/docs/render +0 -0
  77. {sciris-3.5.0 → sciris-3.5.2}/docs/requirements.txt +0 -0
  78. {sciris-3.5.0 → sciris-3.5.2}/docs/sciris-citation.bib +0 -0
  79. {sciris-3.5.0 → sciris-3.5.2}/docs/style_guide.qmd +0 -0
  80. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/.jupyter_cache/__version__.txt +0 -0
  81. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/README.md +0 -0
  82. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/convert_notebooks +0 -0
  83. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/dancing_lines.gif +0 -0
  84. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-advanced.gif +0 -0
  85. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-arrays.png +0 -0
  86. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-dates.png +0 -0
  87. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-dicts.png +0 -0
  88. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-files.png +0 -0
  89. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-intro.png +0 -0
  90. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-parallel.png +0 -0
  91. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-plotting.png +0 -0
  92. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-printing.png +0 -0
  93. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-utils.png +0 -0
  94. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/sim1/sim.py +0 -0
  95. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/sim2/sim.py +0 -0
  96. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_advanced.qmd +0 -0
  97. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_arrays.qmd +0 -0
  98. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_dates.qmd +0 -0
  99. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_dicts.qmd +0 -0
  100. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_files.qmd +0 -0
  101. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_intro.qmd +0 -0
  102. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_parallel.qmd +0 -0
  103. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_plotting.qmd +0 -0
  104. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_printing.qmd +0 -0
  105. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_utils.qmd +0 -0
  106. {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials.qmd +0 -0
  107. {sciris-3.5.0 → sciris-3.5.2}/docs/whatsnew.qmd +0 -0
  108. {sciris-3.5.0 → sciris-3.5.2}/pyproject.toml +0 -0
  109. {sciris-3.5.0 → sciris-3.5.2}/sciris/README.md +0 -0
  110. {sciris-3.5.0 → sciris-3.5.2}/sciris/__init__.py +0 -0
  111. {sciris-3.5.0 → sciris-3.5.2}/sciris/_extras/README.md +0 -0
  112. {sciris-3.5.0 → sciris-3.5.2}/sciris/_extras/__init__.py +0 -0
  113. {sciris-3.5.0 → sciris-3.5.2}/sciris/_extras/ansicolors.py +0 -0
  114. {sciris-3.5.0 → sciris-3.5.2}/sciris/fancy.mplstyle +0 -0
  115. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_asd.py +0 -0
  116. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_colors.py +0 -0
  117. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_dataframe.py +0 -0
  118. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_fileio.py +0 -0
  119. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_math.py +0 -0
  120. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_odict.py +0 -0
  121. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_parallel.py +0 -0
  122. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_plotting.py +0 -0
  123. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_printing.py +0 -0
  124. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_profiling.py +0 -0
  125. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_settings.py +0 -0
  126. {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_versioning.py +0 -0
  127. {sciris-3.5.0 → sciris-3.5.2}/sciris/simple.mplstyle +0 -0
  128. {sciris-3.5.0 → sciris-3.5.2}/sciris.egg-info/dependency_links.txt +0 -0
  129. {sciris-3.5.0 → sciris-3.5.2}/sciris.egg-info/requires.txt +0 -0
  130. {sciris-3.5.0 → sciris-3.5.2}/sciris.egg-info/top_level.txt +0 -0
  131. {sciris-3.5.0 → sciris-3.5.2}/setup.cfg +0 -0
  132. {sciris-3.5.0 → sciris-3.5.2}/tests/.coveragerc +0 -0
  133. {sciris-3.5.0 → sciris-3.5.2}/tests/.pytest_cache/README.md +0 -0
  134. {sciris-3.5.0 → sciris-3.5.2}/tests/README.rst +0 -0
  135. {sciris-3.5.0 → sciris-3.5.2}/tests/check_coverage +0 -0
  136. {sciris-3.5.0 → sciris-3.5.2}/tests/check_style +0 -0
  137. {sciris-3.5.0 → sciris-3.5.2}/tests/files/archive_2023-04-19.zip +0 -0
  138. {sciris-3.5.0 → sciris-3.5.2}/tests/files/deadclass.obj +0 -0
  139. {sciris-3.5.0 → sciris-3.5.2}/tests/files/deadclass.py +0 -0
  140. {sciris-3.5.0 → sciris-3.5.2}/tests/files/exampledata.xlsx +0 -0
  141. {sciris-3.5.0 → sciris-3.5.2}/tests/files/examplefont.ttf +0 -0
  142. {sciris-3.5.0 → sciris-3.5.2}/tests/files/make_archive.py +0 -0
  143. {sciris-3.5.0 → sciris-3.5.2}/tests/files/make_deadclass.py +0 -0
  144. {sciris-3.5.0 → sciris-3.5.2}/tests/files/pickle_2021-01-01.obj +0 -0
  145. {sciris-3.5.0 → sciris-3.5.2}/tests/files/pickle_2022-01-01.obj +0 -0
  146. {sciris-3.5.0 → sciris-3.5.2}/tests/regression/README.rst +0 -0
  147. {sciris-3.5.0 → sciris-3.5.2}/tests/regression/run_downstream +0 -0
  148. {sciris-3.5.0 → sciris-3.5.2}/tests/regression/run_regression +0 -0
  149. {sciris-3.5.0 → sciris-3.5.2}/tests/regression/sciris2021-01-01.yml +0 -0
  150. {sciris-3.5.0 → sciris-3.5.2}/tests/regression/sciris2022-01-01.yml +0 -0
  151. {sciris-3.5.0 → sciris-3.5.2}/tests/regression/sciris2023-01-01.yml +0 -0
  152. {sciris-3.5.0 → sciris-3.5.2}/tests/regression/setup_envs +0 -0
  153. {sciris-3.5.0 → sciris-3.5.2}/tests/requirements.txt +0 -0
  154. {sciris-3.5.0 → sciris-3.5.2}/tests/run_tests +0 -0
  155. {sciris-3.5.0 → sciris-3.5.2}/tests/sc_test_utils.py +0 -0
  156. {sciris-3.5.0 → sciris-3.5.2}/tests/showcase_sciris.py +0 -0
  157. {sciris-3.5.0 → sciris-3.5.2}/tests/showcase_vanilla.py +0 -0
  158. {sciris-3.5.0 → sciris-3.5.2}/tests/test_api.py +0 -0
  159. {sciris-3.5.0 → sciris-3.5.2}/tests/test_asd.py +0 -0
  160. {sciris-3.5.0 → sciris-3.5.2}/tests/test_colors.py +0 -0
  161. {sciris-3.5.0 → sciris-3.5.2}/tests/test_dataframe.py +0 -0
  162. {sciris-3.5.0 → sciris-3.5.2}/tests/test_fileio.py +0 -0
  163. {sciris-3.5.0 → sciris-3.5.2}/tests/test_math.py +0 -0
  164. {sciris-3.5.0 → sciris-3.5.2}/tests/test_odict.py +0 -0
  165. {sciris-3.5.0 → sciris-3.5.2}/tests/test_parallel.py +0 -0
  166. {sciris-3.5.0 → sciris-3.5.2}/tests/test_plotting.py +0 -0
  167. {sciris-3.5.0 → sciris-3.5.2}/tests/test_printing.py +0 -0
  168. {sciris-3.5.0 → sciris-3.5.2}/tests/test_profiling.py +0 -0
  169. {sciris-3.5.0 → sciris-3.5.2}/tests/test_settings.py +0 -0
  170. {sciris-3.5.0 → sciris-3.5.2}/tests/test_versioning.py +0 -0
@@ -4,6 +4,18 @@ 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.2 (2026-10-16)
8
+
9
+ 1. `sc.urlopen()` and `sc.download()` have a new `timeout` argument (default 30 seconds; `None` waits forever, as before), and a new `progress` argument to show a progress bar.
10
+ 1. `sc.urlopen()` now adds the `prefix` to any URL without a scheme (previously, e.g. `httpbin.org` was left alone because it starts with "http"), accepts a string or bytes for `data`, and closes the connection after reading.
11
+ 1. `sc.urlopen()` and `sc.download()` no longer include query strings in default filenames (e.g. `data?a=1` is now saved as `data`).
12
+
13
+ ## Version 3.5.1 (2026-10-15)
14
+
15
+ 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`.
16
+ 1. `sc.search()` no longer matches the root object when searching by key.
17
+ 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).
18
+
7
19
  ## Version 3.5.0 (2026-10-13)
8
20
 
9
21
  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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sciris
3
- Version: 3.5.0
3
+ Version: 3.5.2
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
@@ -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
  ##############################################################################
@@ -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:
@@ -676,7 +683,7 @@ def iterobj(obj, func=None, inplace=False, copy=False, leaf=False, recursion=0,
676
683
  leaf (bool): whether to apply the function only to leaf nodes of the object
677
684
  recursion (int): number of recursive steps to allow, i.e. parsing the same objects multiple times (default 0)
678
685
  depthfirst (bool): whether to parse the object depth-first (default) or breadth-first
679
- 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
680
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
681
688
  rootkey (str): the key to list as the root of the object (default `'root'`)
682
689
  verbose (bool): whether to print progress
@@ -717,6 +724,7 @@ def iterobj(obj, func=None, inplace=False, copy=False, leaf=False, recursion=0,
717
724
  - *New in version 3.1.5:* "recursion" argument; better handling of atomic classes
718
725
  - *New in version 3.1.6:* "skip", "depthfirst", "to_df", and "flatten" arguments
719
726
  - *New in version 3.4.0:* "aliases" argument
727
+ - *New in version 3.5.1:* classes and NumPy dtypes are atomic by default
720
728
  """
721
729
  # Create the object
722
730
  io = IterObj(obj=obj, func=func, inplace=inplace, copy=copy, leaf=leaf, recursion=recursion, aliases=aliases, depthfirst=depthfirst,
@@ -963,23 +971,15 @@ def search(obj, query=_not_given, key=_not_given, value=_not_given, type=_not_gi
963
971
  flatten = kwargs.pop('flatten', False) # Don't flatten because that will disrupt the matching
964
972
  tree = iterobj(obj, **kwargs) # For key matching
965
973
 
966
- # Do the matching
967
- matches = []
968
-
969
- # Match keys
970
- if _is_given(key):
971
- for k in tree.keys():
972
- if check_match(k[-1], key): # Only want the last key of the trace
973
- matches.append(k)
974
-
975
- # Match values (including types)
976
- if _is_given(value):
977
- for k,v in tree.items():
978
- if check_match(v, value):
979
- matches.append(k)
980
-
981
- # Reassemble dict to maintain order
982
- 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)
983
983
 
984
984
  if flatten:
985
985
  out = flatten_traces(out)
@@ -756,7 +756,7 @@ def asciify(string, form='NFKD', encoding='ascii', errors='ignore', **kwargs):
756
756
  __all__ += ['urlopen', 'wget', 'download', 'htmlify']
757
757
 
758
758
  def urlopen(url, filename=None, save=None, headers=None, params=None, data=None,
759
- prefix='http', convert=True, die=False, response='text', verbose=False):
759
+ prefix='http', convert=True, die=False, response='text', timeout=30, progress=False, verbose=False):
760
760
  """
761
761
  Download a single URL.
762
762
 
@@ -769,11 +769,13 @@ def urlopen(url, filename=None, save=None, headers=None, params=None, data=None,
769
769
  save (bool): if supplied instead of `filename`, then use the default filename
770
770
  headers (dict): a dictionary of headers to pass
771
771
  params (dict): a dictionary of parameters to pass to the GET request
772
- data (dict) a dictionary of parameters to pass to a POST request
773
- prefix (str): the string to ensure the URL starts with (else, add it)
772
+ data (dict/str/bytes): a dictionary of parameters to pass to a POST request (or the already-encoded body)
773
+ prefix (str): the scheme to add if the URL doesn't have one (e.g. 'http' turns 'wikipedia.org' into 'http://wikipedia.org')
774
774
  convert (bool): whether to convert from bytes to string
775
775
  die (bool): whether to raise an exception if converting to text failed
776
776
  response (str): what to return: 'text' (default), 'json' (dictionary version of the data), 'status' (the HTTP status), or 'full' (the full response object)
777
+ timeout (float): how many seconds to wait for the server to respond before raising an error (None to wait forever); this limits each connection or read attempt, not the whole download
778
+ progress (bool): whether to show a progress bar while downloading (a running total if the server doesn't give the size)
777
779
  verbose (bool): whether to print progress
778
780
 
779
781
  **Examples**:
@@ -783,12 +785,15 @@ def urlopen(url, filename=None, save=None, headers=None, params=None, data=None,
783
785
  sc.urlopen('http://wikipedia.org', filename='wikipedia.html') # Save to file wikipedia.html
784
786
  sc.urlopen('https://wikipedia.org', save=True, headers={'User-Agent':'Custom agent'}) # Save to the default filename (here, wikipedia.org), with headers
785
787
  sc.urlopen('wikipedia.org', response='status') # Only return the HTTP status of the site
788
+ sc.urlopen('wikipedia.org', timeout=5) # Give up if the server doesn't respond within 5 seconds
789
+ sc.urlopen('https://example.com/bigfile.zip', save=True, progress=True) # Show a progress bar
786
790
  ```
787
791
 
788
792
  - *New in version 2.0.0:* renamed from `wget` to `urlopen`; new arguments
789
793
  - *New in version 2.0.1:* creates folders by default if they do not exist
790
794
  - *New in version 2.0.4:* "prefix" argument, e.g. prepend "http://" if not present
791
795
  - *New in version 3.1.4:* renamed "return_response" to "response"; additional options
796
+ - *New in version 3.5.2:* "timeout" and "progress" arguments; "data" can be a string or bytes; the prefix is added to any URL without a scheme (e.g. "httpbin.org")
792
797
  """
793
798
  from urllib import request as ur # Need to import these directly, not via urllib
794
799
  from urllib import parse as up
@@ -805,24 +810,29 @@ def urlopen(url, filename=None, save=None, headers=None, params=None, data=None,
805
810
 
806
811
  # Handle parameters and data
807
812
  full_url = url
808
- if prefix is not None:
809
- if not full_url.startswith(prefix):
810
- full_url = prefix + '://' + full_url # Leaves https alone, but adds http:// otherwise
813
+ if prefix is not None and '://' not in full_url:
814
+ full_url = prefix + '://' + full_url # Leaves e.g. https:// alone, but adds http:// otherwise
811
815
  if params is not None:
812
816
  full_url += ('&' if '?' in full_url else '?') + up.urlencode(params) # Append to an existing query string if present
813
- if data is not None:
817
+ if isinstance(data, str):
818
+ data = data.encode('utf-8')
819
+ elif data is not None and not isinstance(data, bytes):
814
820
  data = up.urlencode(data).encode(encoding='utf-8', errors='ignore')
815
821
 
816
822
  if verbose: print(f'Downloading {url}...')
817
823
  request = ur.Request(full_url, headers=headers, data=data)
818
824
  try:
819
- resp = ur.urlopen(request) # Actually open the URL
825
+ resp = ur.urlopen(request, timeout=timeout) # Actually open the URL
820
826
  except ue.HTTPError as E: # e.g. 404
821
827
  if response == 'status':
828
+ E.close()
822
829
  return E.code # Return the error status rather than raising
823
830
  raise
831
+ if response != 'full': # Read what we need now, then close the connection
832
+ with resp:
833
+ body = _readurl(resp, progress) if response in ['text', 'json'] else None
824
834
  if response in ['text', 'json']:
825
- output = resp.read()
835
+ output = body
826
836
  if response == 'json':
827
837
  try:
828
838
  output = sc.loadjson(string=output)
@@ -851,7 +861,7 @@ def urlopen(url, filename=None, save=None, headers=None, params=None, data=None,
851
861
  if filename is None and save:
852
862
  filename = resp.headers.get_filename() # From the "Content-Disposition" header, if present
853
863
  if not filename:
854
- filename = url.rstrip('/').split('/')[-1] # Remove trailing /, then pull out the last chunk
864
+ filename = _url_filename(url)
855
865
 
856
866
  if filename is not None and save is not False:
857
867
  if verbose: print(f'Saving to {filename}...')
@@ -875,7 +885,32 @@ def urlopen(url, filename=None, save=None, headers=None, params=None, data=None,
875
885
  wget = urlopen
876
886
 
877
887
 
878
- def download(url, *args, filename=None, save=True, parallel=True, die=True, verbose=True, **kwargs):
888
+ def _readurl(resp, progress=False, chunksize=2**16):
889
+ """ Read an HTTP response, optionally with a progress bar """
890
+ if not progress:
891
+ return resp.read()
892
+ total = resp.length # From the Content-Length header; None if the server doesn't give it
893
+ chunks = []
894
+ done = 0
895
+ while chunk := resp.read(chunksize):
896
+ chunks.append(chunk)
897
+ done += len(chunk)
898
+ label = f'Downloaded {sc.humanize_bytes(done):>10}' # Fixed width, so the line is fully overwritten
899
+ if total:
900
+ sc.progressbar(done, total, label=label, flush=True) # Ends the line when done == total
901
+ else:
902
+ print(f'\r{label}', end='', flush=True)
903
+ if done != total:
904
+ print() # End the line
905
+ return b''.join(chunks)
906
+
907
+
908
+ def _url_filename(url):
909
+ """ Turn e.g. 'http://mysite.com/tree/index.html?a=1' into 'index.html' """
910
+ return url.split('?')[0].split('#')[0].rstrip('/').split('/')[-1]
911
+
912
+
913
+ def download(url, *args, filename=None, save=True, parallel=True, die=True, progress=False, verbose=True, **kwargs):
879
914
  """
880
915
  Download one or more URLs in parallel and return output or save them to disk.
881
916
 
@@ -888,8 +923,9 @@ def download(url, *args, filename=None, save=True, parallel=True, die=True, verb
888
923
  save (bool): if supplied instead of `filename`, then use the default filename
889
924
  parallel (bool): whether to download multiple URLs in parallel
890
925
  die (bool): whether to raise an exception if a URL can't be retrieved (default true)
926
+ progress (bool): whether to show a progress bar (of bytes for a single URL, or of URLs completed for several)
891
927
  verbose (bool): whether to print progress (if verbose=2, print extra detail on each downloaded URL)
892
- **kwargs (dict): passed to `sc.urlopen()`
928
+ **kwargs (dict): passed to `sc.urlopen()` (e.g. `timeout`)
893
929
 
894
930
  **Examples**:
895
931
 
@@ -899,12 +935,14 @@ def download(url, *args, filename=None, save=True, parallel=True, die=True, verb
899
935
  sc.download({'sciris.html':'http://sciris.org', 'covasim.html':'http://covasim.org'}) # Download two and save to disk
900
936
  sc.download(['http://sciris.org', 'http://covasim.org'], filename=['sciris.html', 'covasim.html']) # Ditto
901
937
  data = sc.download(dict(sciris='http://sciris.org', covasim='http://covasim.org'), save=False) # Download and store in memory
938
+ sc.download('https://example.com/bigfile.zip', progress=True, timeout=10) # Show a progress bar, and give up if the server stops responding for 10 seconds
902
939
  ```
903
940
 
904
941
  - *New in version 2.0.0.*
905
942
  - *New in version 3.0.0:* "die" argument
906
943
  - *New in version 3.1.1:* default order switched from URL:filename to filename:URL pairs
907
944
  - *New in version 3.1.3:* output as objdict instead of odict
945
+ - *New in version 3.5.2:* "progress" argument
908
946
  """
909
947
  T = sc.timer()
910
948
 
@@ -927,7 +965,7 @@ def download(url, *args, filename=None, save=True, parallel=True, die=True, verb
927
965
  else: # Otherwise, turn the URL into a filename
928
966
  keys = []
929
967
  for url in urls: # Turn e.g. 'http://mysite.com/tree/index.html' into 'index.html', or 'index.html1' if it's a duplicate
930
- keys.append(uniquename(url.split('/')[-1], keys))
968
+ keys.append(uniquename(_url_filename(url), keys))
931
969
  elif n_keys != n_urls: # pragma: no cover
932
970
  errormsg = f'Cannot process {n_urls} URLs and {n_keys} filenames'
933
971
  raise ValueError(errormsg)
@@ -938,12 +976,12 @@ def download(url, *args, filename=None, save=True, parallel=True, die=True, verb
938
976
  # Get results in parallel
939
977
  wget_verbose = (verbose>1) or (verbose and n_urls == 1) # By default, don't print progress on each download
940
978
  iterkwargs = dict(url=urls, filename=keys)
941
- func_kwargs = mergedicts(dict(save=save, verbose=wget_verbose), kwargs)
979
+ func_kwargs = mergedicts(dict(save=save, verbose=wget_verbose, progress=progress and n_urls == 1), kwargs) # For several URLs, show progress by URL instead of by byte
942
980
  if n_urls > 1 and parallel:
943
- outputs = sc.parallelize(urlopen, iterkwargs=iterkwargs, kwargs=func_kwargs, parallelizer='thread', die=die)
981
+ outputs = sc.parallelize(urlopen, iterkwargs=iterkwargs, kwargs=func_kwargs, parallelizer='thread', progress=progress, die=die)
944
982
  else:
945
983
  outputs = []
946
- for key,url in zip(keys, urls):
984
+ for i,(key,url) in enumerate(zip(keys, urls)):
947
985
  try:
948
986
  output = urlopen(url=url, filename=key, **func_kwargs)
949
987
  except Exception as E:
@@ -955,6 +993,8 @@ def download(url, *args, filename=None, save=True, parallel=True, die=True, verb
955
993
  output = E
956
994
 
957
995
  outputs.append(output)
996
+ if progress and n_urls > 1:
997
+ sc.progressbar(i+1, n_urls, label=f'URL {i+1}/{n_urls}', flush=True)
958
998
 
959
999
  # If we're returning the data rather than saving the files, convert to an odict
960
1000
  if not save:
@@ -4,6 +4,6 @@ Version and license information.
4
4
 
5
5
  __all__ = ['__version__', '__versiondate__', '__license__']
6
6
 
7
- __version__ = '3.5.0'
8
- __versiondate__ = '2026-10-13'
7
+ __version__ = '3.5.2'
8
+ __versiondate__ = '2026-10-16'
9
9
  __license__ = f'Sciris {__version__} ({__versiondate__}) – © 2014-2026 by the Sciris Development Team'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sciris
3
- Version: 3.5.0
3
+ Version: 3.5.2
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
@@ -9,7 +9,6 @@ MANIFEST.in
9
9
  README.md
10
10
  STYLE_GUIDE.md
11
11
  context7.json
12
- objdict_performance.md
13
12
  pyproject.toml
14
13
  .claude-plugin/marketplace.json
15
14
  .github/workflows/publish_docs.yaml
@@ -1,5 +1,6 @@
1
1
  [pytest]
2
2
  filterwarnings =
3
3
  ignore::pytest.PytestReturnNotNoneWarning
4
+ faulthandler_timeout = 300
4
5
  env =
5
6
  SCIRIS_BACKEND=agg
@@ -126,6 +126,13 @@ def test_dates():
126
126
  o.year = sc.datetoyear('2010-07-01')
127
127
  assert sc.datetoyear(dt.datetime(2010, 7, 1, 12)) > o.year # Keeps the time of day
128
128
 
129
+ print('\nTesting yeartodate')
130
+ o.date = sc.yeartodate(2010.5)
131
+ assert o.date == dt.date(2010, 7, 2)
132
+ assert sc.yeartodate(2010.5, as_date=False) == '2010-07-02'
133
+ years = [2010.5, 2012.999, 2024.2]
134
+ assert sc.yeartodate(np.array(years)) == [sc.yeartodate(y) for y in years] # Arrays give the same dates
135
+
129
136
  print('\nTesting datedelta')
130
137
  o.dd = sc.datedelta('2021-07-07', 3) # Add 3 days
131
138
  assert o.dd == '2021-07-10'
@@ -238,6 +238,7 @@ def test_search():
238
238
  assert len(valmatches) == 1
239
239
  assert sc.getnested(nested, valmatches[0]) == val # Get from the original nested object
240
240
  assert sc.search(dict(a=np.arange(5), b=3), value=3).keys() == [('b',)] # Arrays are skipped rather than raising an error
241
+ assert not len(sc.search(dict(a=1), key='t')) # The root has no key, so doesn't match
241
242
 
242
243
  return o
243
244
 
@@ -297,6 +298,7 @@ def test_iterobj():
297
298
  d = dict(t=(1,(2,3)))
298
299
  sc.iterobj(d, lambda x: list(x) if isinstance(x, tuple) else x, atomic='default-tuple', inplace=True)
299
300
  assert d == dict(t=[1,[2,3]]) # Nested tuples are converted too, since it descends into the new object
301
+ assert len(sc.iterobj(dict(a=np.int64, b=np.dtype(float)))) == 3 # Classes and dtypes aren't descended into
300
302
 
301
303
  return out1
302
304
 
@@ -91,11 +91,17 @@ def test_urlopen_local():
91
91
  print('\nTesting urlopen with a local server')
92
92
 
93
93
  class Handler(BaseHTTPRequestHandler):
94
+ def log_message(self, *args):
95
+ pass # Don't print each request
96
+
94
97
  def do_GET(self):
95
98
  if self.path == '/missing':
96
99
  self.send_response(404)
97
100
  self.end_headers()
98
101
  return
102
+ if self.path == '/slow':
103
+ sc.timedsleep(1)
104
+ return # The client has timed out by now, so don't reply
99
105
  self.send_response(200)
100
106
  if self.path == '/latin1':
101
107
  self.send_header('Content-Type', 'text/plain; charset=iso-8859-1')
@@ -103,6 +109,14 @@ def test_urlopen_local():
103
109
  else:
104
110
  self.send_header('Content-Disposition', 'attachment; filename="temp_report.csv"')
105
111
  body = self.path.encode()
112
+ if self.path == '/sized':
113
+ self.send_header('Content-Length', str(len(body)))
114
+ self.end_headers()
115
+ self.wfile.write(body)
116
+
117
+ def do_POST(self):
118
+ body = self.rfile.read(int(self.headers['Content-Length']))
119
+ self.send_response(200)
106
120
  self.end_headers()
107
121
  self.wfile.write(body)
108
122
 
@@ -113,8 +127,14 @@ def test_urlopen_local():
113
127
  assert sc.urlopen(url + '/missing', response='status') == 404
114
128
  assert sc.urlopen(url + '/api?a=1', params=dict(b=2)) == '/api?a=1&b=2'
115
129
  assert sc.urlopen(url + '/latin1') == 'café'
116
- data = sc.download(f'http://{url}/v1/data', f'http://{url}/v2/data', save=False)
117
- assert list(data.values()) == ['/v1/data', '/v2/data'] # Duplicate filenames are not merged
130
+ assert sc.urlopen(url + '/post', data=b'raw') == 'raw'
131
+ assert sc.urlopen(url + '/sized', progress=True) == '/sized' # With Content-Length
132
+ assert sc.urlopen(url + '/unsized', progress=True) == '/unsized' # Without
133
+ with pytest.raises(TimeoutError):
134
+ sc.urlopen(url + '/slow', timeout=0.1)
135
+ data = sc.download(f'http://{url}/v1/data', f'http://{url}/v2/data?a=1', save=False, progress=True)
136
+ assert list(data.keys()) == ['data', 'data1'] # Query strings are not part of the filename
137
+ assert list(data.values()) == ['/v1/data', '/v2/data?a=1'] # Duplicate filenames are not merged
118
138
  filename = sc.urlopen(url + '/report', save=True) # Filename from the Content-Disposition header
119
139
  assert sc.path(filename).name == 'temp_report.csv'
120
140
  sc.rmpath(filename)
@@ -1,89 +0,0 @@
1
- # `sc.objdict` attribute access is slow
2
-
3
- ## Summary
4
-
5
- Reading a key from an `sc.objdict` as an attribute (`obj.a`) takes about 530 ns, roughly 35 times slower than `types.SimpleNamespace` or `sc.dictobj` (15 ns) and 6 times slower than reading the same key as an item (`obj['a']`, 90 ns). Writing via an attribute (`obj.a = 1`) takes about 1150 ns, roughly 60 times slower than `SimpleNamespace`. This is invisible in normal use, but it adds up when an `objdict` is read inside a hot loop.
6
-
7
- ## How it was found
8
-
9
- In the Starsim v4 ODE solver (`starsim/solvers.py` on the `v4` branch), the right-hand side function read its per-compartment and per-flow records from `objdict`s. Profiling showed attribute access on those objects was about a third of the right-hand side's run time. Swapping the records read in the hot loop for `types.SimpleNamespace` removed the cost. The solver still uses `objdict` everywhere outside the hot loop.
10
-
11
- ## Measurements
12
-
13
- Sciris 3.4.0, Python 3.13, Linux; best of 5 runs of 1,000,000 operations each.
14
-
15
- | Object | `obj.a` | `obj['a']` | `obj.a = 1.0` |
16
- |---|---|---|---|
17
- | `dict` | n/a | 10 ns | n/a |
18
- | `types.SimpleNamespace` | 15 ns | n/a | 19 ns |
19
- | `sc.odict` | n/a | 65 ns | n/a |
20
- | `sc.dictobj` | 15 ns | 81 ns | 20 ns |
21
- | `sc.objdict` | 531 ns | 91 ns | 1148 ns |
22
-
23
- ```python
24
- import timeit
25
- import types
26
- import sciris as sc
27
-
28
- n = 1_000_000
29
- data = dict(a=1.0, b=2.0, c=3.0)
30
- objs = dict(
31
- SimpleNamespace = types.SimpleNamespace(**data),
32
- dictobj = sc.dictobj(data),
33
- objdict = sc.objdict(data),
34
- )
35
- for name,obj in objs.items():
36
- for stmt in ['obj.a', 'obj.a = 1.0']:
37
- t = min(timeit.repeat(stmt, globals=dict(obj=obj), number=n, repeat=5))/n*1e9
38
- print(f'{name:16s} {stmt:12s} {t:6.0f} ns')
39
- ```
40
-
41
- ## Cause
42
-
43
- `objdict.__getattribute__` (in `sciris/sc_odict.py`) tries the name as a real attribute first and falls back to the dict only if that fails:
44
-
45
- ```python
46
- try: # First, try to get the attribute as an attribute
47
- return object.__getattribute__(self, attr)
48
- except Exception as E: # If that fails, try to get it as a dict item, but pass along the original exception
49
- return self.__getitem__(attr, exception=E)
50
- ```
51
-
52
- So every successful read of a key raises and catches an `AttributeError` before reaching the dict, and then goes through two more Python-level calls (`objdict.__getitem__`, then `odict.__getitem__`). Creating and catching the exception is most of the cost.
53
-
54
- `objdict.__setattr__` does the same thing: it calls `object.__getattribute__` to check that the name is not an existing attribute, which raises for every ordinary key, and then calls `__setitem__`.
55
-
56
- Reading a method or other real attribute (`obj.keys`) does not hit the exception and takes about 90 ns.
57
-
58
- ## Possible fix
59
-
60
- Check the dict first, and use it directly when the name is a key and is not also an attribute of the class. Methods still win over keys of the same name, so behavior is unchanged; everything else falls through to the existing code.
61
-
62
- ```python
63
- def __getattribute__(self, attr):
64
- if dict.__contains__(self, attr) and not hasattr(type(self), attr): # A key that doesn't shadow a method
65
- return dict.__getitem__(self, attr)
66
- ... # Existing code
67
-
68
- def __setattr__(self, name, value):
69
- if dict.__contains__(self, name) and not hasattr(type(self), name): # An existing key
70
- return dict.__setitem__(self, name, value)
71
- ... # Existing code
72
- ```
73
-
74
- Timings for a prototype of this, written as a subclass of `sc.objdict`:
75
-
76
- | Operation | Current | Prototype |
77
- |---|---|---|
78
- | `obj.a` (existing key) | 532 ns | 62 ns |
79
- | `obj.a = 1.0` (existing key) | 1178 ns | 101 ns |
80
- | `obj.keys` (method) | 89 ns | 140 ns |
81
- | `obj.new = 1.0; del obj['new']` (new key) | 1493 ns | 1819 ns |
82
-
83
- Reading and writing existing keys become about 9 and 12 times faster. Method lookups and setting new keys become slower (by about 50 ns and 330 ns), because they now pay for the extra check before falling through; some of that is the overhead of the subclass calling the parent method, which a change made in `objdict` itself would not have.
84
-
85
- The prototype passed a few quick checks (a key named `keys` does not shadow the method, integer indexing still works, `defaultdict='nested'` still works), but it has not been run against the Sciris test suite, and nothing in Sciris has been changed.
86
-
87
- ## Workaround
88
-
89
- In a hot loop, read `objdict` keys as items (`obj['a']`), or hold the records in a `types.SimpleNamespace` or `sc.dictobj` instead.