sciris 3.5.1__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.1 → sciris-3.5.2}/CHANGELOG.md +6 -0
  2. {sciris-3.5.1/sciris.egg-info → sciris-3.5.2}/PKG-INFO +1 -1
  3. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_utils.py +56 -16
  4. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_version.py +2 -2
  5. {sciris-3.5.1 → sciris-3.5.2/sciris.egg-info}/PKG-INFO +1 -1
  6. {sciris-3.5.1 → sciris-3.5.2}/sciris.egg-info/SOURCES.txt +0 -1
  7. {sciris-3.5.1 → sciris-3.5.2}/tests/pytest.ini +1 -0
  8. {sciris-3.5.1 → sciris-3.5.2}/tests/test_utils.py +22 -2
  9. sciris-3.5.1/sciris_issues.md +0 -21
  10. {sciris-3.5.1 → sciris-3.5.2}/.claude-plugin/marketplace.json +0 -0
  11. {sciris-3.5.1 → sciris-3.5.2}/.github/workflows/publish_docs.yaml +0 -0
  12. {sciris-3.5.1 → sciris-3.5.2}/.github/workflows/pypi_release.yaml +0 -0
  13. {sciris-3.5.1 → sciris-3.5.2}/.github/workflows/schedule_tests.yaml +0 -0
  14. {sciris-3.5.1 → sciris-3.5.2}/.github/workflows/test_docs.yaml +0 -0
  15. {sciris-3.5.1 → sciris-3.5.2}/.github/workflows/test_downstream.yaml +0 -0
  16. {sciris-3.5.1 → sciris-3.5.2}/.github/workflows/test_sciris.yaml +0 -0
  17. {sciris-3.5.1 → sciris-3.5.2}/.gitignore +0 -0
  18. {sciris-3.5.1 → sciris-3.5.2}/.pylintrc +0 -0
  19. {sciris-3.5.1 → sciris-3.5.2}/.pytest_cache/README.md +0 -0
  20. {sciris-3.5.1 → sciris-3.5.2}/CLAUDE.md +0 -0
  21. {sciris-3.5.1 → sciris-3.5.2}/CODE_OF_CONDUCT.md +0 -0
  22. {sciris-3.5.1 → sciris-3.5.2}/CONTRIBUTING.md +0 -0
  23. {sciris-3.5.1 → sciris-3.5.2}/LICENSE +0 -0
  24. {sciris-3.5.1 → sciris-3.5.2}/MANIFEST.in +0 -0
  25. {sciris-3.5.1 → sciris-3.5.2}/README.md +0 -0
  26. {sciris-3.5.1 → sciris-3.5.2}/STYLE_GUIDE.md +0 -0
  27. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/.claude-plugin/plugin.json +0 -0
  28. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/README.md +0 -0
  29. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-advanced/SKILL.md +0 -0
  30. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-arrays/SKILL.md +0 -0
  31. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-dates/SKILL.md +0 -0
  32. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-dicts/SKILL.md +0 -0
  33. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-files/SKILL.md +0 -0
  34. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-intro/SKILL.md +0 -0
  35. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-parallel/SKILL.md +0 -0
  36. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-plotting/SKILL.md +0 -0
  37. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-printing/SKILL.md +0 -0
  38. {sciris-3.5.1 → sciris-3.5.2}/claude_plugin/skills/sciris-utils/SKILL.md +0 -0
  39. {sciris-3.5.1 → sciris-3.5.2}/context7.json +0 -0
  40. {sciris-3.5.1 → sciris-3.5.2}/docs/.gitignore +0 -0
  41. {sciris-3.5.1 → sciris-3.5.2}/docs/404.qmd +0 -0
  42. {sciris-3.5.1 → sciris-3.5.2}/docs/CNAME +0 -0
  43. {sciris-3.5.1 → sciris-3.5.2}/docs/README.md +0 -0
  44. {sciris-3.5.1 → sciris-3.5.2}/docs/_extensions/machow/interlinks/objects.txt +0 -0
  45. {sciris-3.5.1 → sciris-3.5.2}/docs/_quarto.yml +0 -0
  46. {sciris-3.5.1 → sciris-3.5.2}/docs/_site/llms-full.txt +0 -0
  47. {sciris-3.5.1 → sciris-3.5.2}/docs/_site/llms.txt +0 -0
  48. {sciris-3.5.1 → sciris-3.5.2}/docs/_site/robots.txt +0 -0
  49. {sciris-3.5.1 → sciris-3.5.2}/docs/_variables.yml +0 -0
  50. {sciris-3.5.1 → sciris-3.5.2}/docs/api/index.qmd +0 -0
  51. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/favicon.ico +0 -0
  52. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/sciris-logo-dark.png +0 -0
  53. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/sciris-logo-grey.png +0 -0
  54. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/sciris-logo-large.png +0 -0
  55. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/sciris-logo.png +0 -0
  56. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/sciris-showcase-code.png +0 -0
  57. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/sciris-showcase-output.png +0 -0
  58. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/styles-dark.scss +0 -0
  59. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/styles-light.scss +0 -0
  60. {sciris-3.5.1 → sciris-3.5.2}/docs/assets/styles.css +0 -0
  61. {sciris-3.5.1 → sciris-3.5.2}/docs/check_notebooks.py +0 -0
  62. {sciris-3.5.1 → sciris-3.5.2}/docs/clean_all +0 -0
  63. {sciris-3.5.1 → sciris-3.5.2}/docs/clean_outputs.py +0 -0
  64. {sciris-3.5.1 → sciris-3.5.2}/docs/conduct.qmd +0 -0
  65. {sciris-3.5.1 → sciris-3.5.2}/docs/contributing.qmd +0 -0
  66. {sciris-3.5.1 → sciris-3.5.2}/docs/index.qmd +0 -0
  67. {sciris-3.5.1 → sciris-3.5.2}/docs/make_api.py +0 -0
  68. {sciris-3.5.1 → sciris-3.5.2}/docs/overview.qmd +0 -0
  69. {sciris-3.5.1 → sciris-3.5.2}/docs/preview +0 -0
  70. {sciris-3.5.1 → sciris-3.5.2}/docs/publish +0 -0
  71. {sciris-3.5.1 → sciris-3.5.2}/docs/quarto_utils.py +0 -0
  72. {sciris-3.5.1 → sciris-3.5.2}/docs/render +0 -0
  73. {sciris-3.5.1 → sciris-3.5.2}/docs/requirements.txt +0 -0
  74. {sciris-3.5.1 → sciris-3.5.2}/docs/sciris-citation.bib +0 -0
  75. {sciris-3.5.1 → sciris-3.5.2}/docs/style_guide.qmd +0 -0
  76. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/.jupyter_cache/__version__.txt +0 -0
  77. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/README.md +0 -0
  78. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/convert_notebooks +0 -0
  79. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/dancing_lines.gif +0 -0
  80. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-advanced.gif +0 -0
  81. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-arrays.png +0 -0
  82. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-dates.png +0 -0
  83. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-dicts.png +0 -0
  84. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-files.png +0 -0
  85. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-intro.png +0 -0
  86. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-parallel.png +0 -0
  87. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-plotting.png +0 -0
  88. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-printing.png +0 -0
  89. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/img-utils.png +0 -0
  90. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/sim1/sim.py +0 -0
  91. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/sim2/sim.py +0 -0
  92. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_advanced.qmd +0 -0
  93. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_arrays.qmd +0 -0
  94. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_dates.qmd +0 -0
  95. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_dicts.qmd +0 -0
  96. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_files.qmd +0 -0
  97. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_intro.qmd +0 -0
  98. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_parallel.qmd +0 -0
  99. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_plotting.qmd +0 -0
  100. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_printing.qmd +0 -0
  101. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials/tut_utils.qmd +0 -0
  102. {sciris-3.5.1 → sciris-3.5.2}/docs/tutorials.qmd +0 -0
  103. {sciris-3.5.1 → sciris-3.5.2}/docs/whatsnew.qmd +0 -0
  104. {sciris-3.5.1 → sciris-3.5.2}/pyproject.toml +0 -0
  105. {sciris-3.5.1 → sciris-3.5.2}/sciris/README.md +0 -0
  106. {sciris-3.5.1 → sciris-3.5.2}/sciris/__init__.py +0 -0
  107. {sciris-3.5.1 → sciris-3.5.2}/sciris/_extras/README.md +0 -0
  108. {sciris-3.5.1 → sciris-3.5.2}/sciris/_extras/__init__.py +0 -0
  109. {sciris-3.5.1 → sciris-3.5.2}/sciris/_extras/ansicolors.py +0 -0
  110. {sciris-3.5.1 → sciris-3.5.2}/sciris/fancy.mplstyle +0 -0
  111. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_asd.py +0 -0
  112. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_colors.py +0 -0
  113. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_dataframe.py +0 -0
  114. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_datetime.py +0 -0
  115. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_fileio.py +0 -0
  116. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_math.py +0 -0
  117. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_nested.py +0 -0
  118. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_odict.py +0 -0
  119. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_parallel.py +0 -0
  120. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_plotting.py +0 -0
  121. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_printing.py +0 -0
  122. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_profiling.py +0 -0
  123. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_settings.py +0 -0
  124. {sciris-3.5.1 → sciris-3.5.2}/sciris/sc_versioning.py +0 -0
  125. {sciris-3.5.1 → sciris-3.5.2}/sciris/simple.mplstyle +0 -0
  126. {sciris-3.5.1 → sciris-3.5.2}/sciris.egg-info/dependency_links.txt +0 -0
  127. {sciris-3.5.1 → sciris-3.5.2}/sciris.egg-info/requires.txt +0 -0
  128. {sciris-3.5.1 → sciris-3.5.2}/sciris.egg-info/top_level.txt +0 -0
  129. {sciris-3.5.1 → sciris-3.5.2}/setup.cfg +0 -0
  130. {sciris-3.5.1 → sciris-3.5.2}/tests/.coveragerc +0 -0
  131. {sciris-3.5.1 → sciris-3.5.2}/tests/.pytest_cache/README.md +0 -0
  132. {sciris-3.5.1 → sciris-3.5.2}/tests/README.rst +0 -0
  133. {sciris-3.5.1 → sciris-3.5.2}/tests/check_coverage +0 -0
  134. {sciris-3.5.1 → sciris-3.5.2}/tests/check_style +0 -0
  135. {sciris-3.5.1 → sciris-3.5.2}/tests/files/archive_2023-04-19.zip +0 -0
  136. {sciris-3.5.1 → sciris-3.5.2}/tests/files/deadclass.obj +0 -0
  137. {sciris-3.5.1 → sciris-3.5.2}/tests/files/deadclass.py +0 -0
  138. {sciris-3.5.1 → sciris-3.5.2}/tests/files/exampledata.xlsx +0 -0
  139. {sciris-3.5.1 → sciris-3.5.2}/tests/files/examplefont.ttf +0 -0
  140. {sciris-3.5.1 → sciris-3.5.2}/tests/files/make_archive.py +0 -0
  141. {sciris-3.5.1 → sciris-3.5.2}/tests/files/make_deadclass.py +0 -0
  142. {sciris-3.5.1 → sciris-3.5.2}/tests/files/pickle_2021-01-01.obj +0 -0
  143. {sciris-3.5.1 → sciris-3.5.2}/tests/files/pickle_2022-01-01.obj +0 -0
  144. {sciris-3.5.1 → sciris-3.5.2}/tests/regression/README.rst +0 -0
  145. {sciris-3.5.1 → sciris-3.5.2}/tests/regression/run_downstream +0 -0
  146. {sciris-3.5.1 → sciris-3.5.2}/tests/regression/run_regression +0 -0
  147. {sciris-3.5.1 → sciris-3.5.2}/tests/regression/sciris2021-01-01.yml +0 -0
  148. {sciris-3.5.1 → sciris-3.5.2}/tests/regression/sciris2022-01-01.yml +0 -0
  149. {sciris-3.5.1 → sciris-3.5.2}/tests/regression/sciris2023-01-01.yml +0 -0
  150. {sciris-3.5.1 → sciris-3.5.2}/tests/regression/setup_envs +0 -0
  151. {sciris-3.5.1 → sciris-3.5.2}/tests/requirements.txt +0 -0
  152. {sciris-3.5.1 → sciris-3.5.2}/tests/run_tests +0 -0
  153. {sciris-3.5.1 → sciris-3.5.2}/tests/sc_test_utils.py +0 -0
  154. {sciris-3.5.1 → sciris-3.5.2}/tests/showcase_sciris.py +0 -0
  155. {sciris-3.5.1 → sciris-3.5.2}/tests/showcase_vanilla.py +0 -0
  156. {sciris-3.5.1 → sciris-3.5.2}/tests/test_api.py +0 -0
  157. {sciris-3.5.1 → sciris-3.5.2}/tests/test_asd.py +0 -0
  158. {sciris-3.5.1 → sciris-3.5.2}/tests/test_colors.py +0 -0
  159. {sciris-3.5.1 → sciris-3.5.2}/tests/test_dataframe.py +0 -0
  160. {sciris-3.5.1 → sciris-3.5.2}/tests/test_datetime.py +0 -0
  161. {sciris-3.5.1 → sciris-3.5.2}/tests/test_fileio.py +0 -0
  162. {sciris-3.5.1 → sciris-3.5.2}/tests/test_math.py +0 -0
  163. {sciris-3.5.1 → sciris-3.5.2}/tests/test_nested.py +0 -0
  164. {sciris-3.5.1 → sciris-3.5.2}/tests/test_odict.py +0 -0
  165. {sciris-3.5.1 → sciris-3.5.2}/tests/test_parallel.py +0 -0
  166. {sciris-3.5.1 → sciris-3.5.2}/tests/test_plotting.py +0 -0
  167. {sciris-3.5.1 → sciris-3.5.2}/tests/test_printing.py +0 -0
  168. {sciris-3.5.1 → sciris-3.5.2}/tests/test_profiling.py +0 -0
  169. {sciris-3.5.1 → sciris-3.5.2}/tests/test_settings.py +0 -0
  170. {sciris-3.5.1 → sciris-3.5.2}/tests/test_versioning.py +0 -0
@@ -4,6 +4,12 @@ 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
+
7
13
  ## Version 3.5.1 (2026-10-15)
8
14
 
9
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`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sciris
3
- Version: 3.5.1
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
@@ -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.1'
8
- __versiondate__ = '2026-10-15'
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.1
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
@@ -10,7 +10,6 @@ README.md
10
10
  STYLE_GUIDE.md
11
11
  context7.json
12
12
  pyproject.toml
13
- sciris_issues.md
14
13
  .claude-plugin/marketplace.json
15
14
  .github/workflows/publish_docs.yaml
16
15
  .github/workflows/pypi_release.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
@@ -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,21 +0,0 @@
1
- # Sciris issues
2
-
3
- Found while speeding up how Starsim v4 builds a sim (Sciris as installed in the `v4` env). Nothing here has been changed in Sciris.
4
-
5
- ## `sc.search()` builds the whole tree before matching
6
-
7
- `sc.search()` calls `sc.iterobj()` with the default function, which stores every object it visits in `output`, keyed by `tuple(trace)` (an `sc.objdict` `__setitem__` per object), and only then filters that tree for matches. For a search by type, almost all of that work is thrown away. Building a Starsim sim does five such searches (for distributions and time parameters), and they cost about 3 µs per object visited. That was most of the time to build a small sim (about 8 of 13 ms before the changes in Starsim, and about 4 of 7 ms after them).
8
-
9
- Suggested fix: give `iterobj()` a mode in which the function only records matches (or let `search()` pass a function that keeps only the objects that match, and not store the rest), so a search costs only the walk.
10
-
11
- ## `IterObj` deep-copies the `skip` dict on every call
12
-
13
- `IterObj.__init__` does `skip = sc.dcp(skip)` when `skip` is a dict. Starsim passes a list of a few hundred object IDs in `skip['ids']`, so each search starts by deep-copying it. A shallow copy (or `dict(skip)`, since the values are only read) would do.
14
-
15
- ## `sc.iterobj()` descends into classes and NumPy dtypes
16
-
17
- With the default `atomic`, a class object (e.g. `np.int64` stored as an attribute `dtype`) is treated as an object and its `__dict__` is walked: 49 extra visits for one such attribute. Classes and `np.dtype` instances could be atomic by default, as strings and arrays are. (Starsim now skips them itself.)
18
-
19
- ## `sc.yeartodate()` is slow
20
-
21
- About 65 µs a call, most of it in `sc.datedelta()`, which sanitizes its arguments with `sc.dcp()` and goes through `dateutil.relativedelta`. `ss.date.from_year()` called it once per time point, which made building a sim with weekly steps over 30 years take about 70 ms. Starsim now computes the date directly (1 January of the year, plus `round(fraction × days in the year)` days, which gives identical dates on 95,445 test years). The same shortcut in `yeartodate()` itself would help other callers. A vectorized form, for an array of years, would help more.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes