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.
- {sciris-3.5.0 → sciris-3.5.2}/CHANGELOG.md +12 -0
- {sciris-3.5.0/sciris.egg-info → sciris-3.5.2}/PKG-INFO +1 -1
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_datetime.py +24 -12
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_nested.py +36 -36
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_utils.py +56 -16
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_version.py +2 -2
- {sciris-3.5.0 → sciris-3.5.2/sciris.egg-info}/PKG-INFO +1 -1
- {sciris-3.5.0 → sciris-3.5.2}/sciris.egg-info/SOURCES.txt +0 -1
- {sciris-3.5.0 → sciris-3.5.2}/tests/pytest.ini +1 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_datetime.py +7 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_nested.py +2 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_utils.py +22 -2
- sciris-3.5.0/objdict_performance.md +0 -89
- {sciris-3.5.0 → sciris-3.5.2}/.claude-plugin/marketplace.json +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/publish_docs.yaml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/pypi_release.yaml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/schedule_tests.yaml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/test_docs.yaml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/test_downstream.yaml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/.github/workflows/test_sciris.yaml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/.gitignore +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/.pylintrc +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/.pytest_cache/README.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/CLAUDE.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/CODE_OF_CONDUCT.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/CONTRIBUTING.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/LICENSE +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/MANIFEST.in +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/README.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/STYLE_GUIDE.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/.claude-plugin/plugin.json +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/README.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-advanced/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-arrays/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-dates/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-dicts/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-files/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-intro/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-parallel/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-plotting/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-printing/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/claude_plugin/skills/sciris-utils/SKILL.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/context7.json +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/.gitignore +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/404.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/CNAME +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/README.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/_extensions/machow/interlinks/objects.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/_quarto.yml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/_site/llms-full.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/_site/llms.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/_site/robots.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/_variables.yml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/api/index.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/favicon.ico +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-logo-dark.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-logo-grey.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-logo-large.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-logo.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-showcase-code.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/sciris-showcase-output.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/styles-dark.scss +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/styles-light.scss +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/assets/styles.css +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/check_notebooks.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/clean_all +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/clean_outputs.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/conduct.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/contributing.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/index.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/make_api.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/overview.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/preview +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/publish +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/quarto_utils.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/render +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/requirements.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/sciris-citation.bib +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/style_guide.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/.jupyter_cache/__version__.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/README.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/convert_notebooks +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/dancing_lines.gif +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-advanced.gif +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-arrays.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-dates.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-dicts.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-files.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-intro.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-parallel.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-plotting.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-printing.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/img-utils.png +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/sim1/sim.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/sim2/sim.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_advanced.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_arrays.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_dates.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_dicts.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_files.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_intro.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_parallel.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_plotting.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_printing.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials/tut_utils.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/tutorials.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/docs/whatsnew.qmd +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/pyproject.toml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/README.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/__init__.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/_extras/README.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/_extras/__init__.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/_extras/ansicolors.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/fancy.mplstyle +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_asd.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_colors.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_dataframe.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_fileio.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_math.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_odict.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_parallel.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_plotting.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_printing.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_profiling.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_settings.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/sc_versioning.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris/simple.mplstyle +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris.egg-info/dependency_links.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris.egg-info/requires.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/sciris.egg-info/top_level.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/setup.cfg +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/.coveragerc +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/.pytest_cache/README.md +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/README.rst +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/check_coverage +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/check_style +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/files/archive_2023-04-19.zip +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/files/deadclass.obj +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/files/deadclass.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/files/exampledata.xlsx +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/files/examplefont.ttf +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/files/make_archive.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/files/make_deadclass.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/files/pickle_2021-01-01.obj +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/files/pickle_2022-01-01.obj +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/regression/README.rst +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/regression/run_downstream +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/regression/run_regression +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/regression/sciris2021-01-01.yml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/regression/sciris2022-01-01.yml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/regression/sciris2023-01-01.yml +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/regression/setup_envs +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/requirements.txt +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/run_tests +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/sc_test_utils.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/showcase_sciris.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/showcase_vanilla.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_api.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_asd.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_colors.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_dataframe.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_fileio.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_math.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_odict.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_parallel.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_plotting.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_printing.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_profiling.py +0 -0
- {sciris-3.5.0 → sciris-3.5.2}/tests/test_settings.py +0 -0
- {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.
|
|
@@ -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
|
|
666
|
-
as_date (bool): If True (default), return
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
8
|
-
__versiondate__ = '2026-10-
|
|
7
|
+
__version__ = '3.5.2'
|
|
8
|
+
__versiondate__ = '2026-10-16'
|
|
9
9
|
__license__ = f'Sciris {__version__} ({__versiondate__}) – © 2014-2026 by the Sciris Development Team'
|
|
@@ -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
|
-
|
|
117
|
-
assert
|
|
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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|