fleche 0.22.2__tar.gz → 0.22.4__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 (238) hide show
  1. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/test-minimum-deps.yml +6 -1
  2. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/tests.yml +23 -0
  3. {fleche-0.22.2 → fleche-0.22.4}/.gitignore +4 -0
  4. fleche-0.22.4/.release-please-manifest.json +3 -0
  5. {fleche-0.22.2 → fleche-0.22.4}/CHANGELOG.md +14 -0
  6. {fleche-0.22.2 → fleche-0.22.4}/PKG-INFO +2 -2
  7. {fleche-0.22.2 → fleche-0.22.4}/agents/DEVELOPING.md +129 -53
  8. {fleche-0.22.2 → fleche-0.22.4}/docs/dev/function_profile.rst +5 -0
  9. {fleche-0.22.2 → fleche-0.22.4}/docs/dev/ssh_cache.rst +3 -3
  10. {fleche-0.22.2 → fleche-0.22.4}/docs/digests/digest_equivalence.rst +69 -16
  11. {fleche-0.22.2 → fleche-0.22.4/docs}/notebooks/ConcurrentExecution.ipynb +2 -2
  12. {fleche-0.22.2 → fleche-0.22.4}/docs/parallel_execution.rst +32 -37
  13. {fleche-0.22.2 → fleche-0.22.4}/docs/storage/configuration.rst +29 -16
  14. {fleche-0.22.2 → fleche-0.22.4}/docs/usage/helpers.rst +10 -1
  15. {fleche-0.22.2 → fleche-0.22.4}/docs/usage/lazy_call.rst +8 -0
  16. {fleche-0.22.2 → fleche-0.22.4}/docs/usage/query.rst +36 -1
  17. {fleche-0.22.2/docs → fleche-0.22.4}/notebooks/ConcurrentExecution.ipynb +2 -2
  18. {fleche-0.22.2 → fleche-0.22.4}/pyproject.toml +1 -1
  19. fleche-0.22.4/src/fleche/_hashing.py +60 -0
  20. fleche-0.22.4/src/fleche/_numbers.py +283 -0
  21. fleche-0.22.4/src/fleche/_numpy.py +45 -0
  22. fleche-0.22.4/src/fleche/_pandas.py +114 -0
  23. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/_version.py +3 -3
  24. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/call.py +3 -1
  25. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/digest.py +27 -66
  26. {fleche-0.22.2 → fleche-0.22.4}/src/fleche.egg-info/PKG-INFO +2 -2
  27. {fleche-0.22.2 → fleche-0.22.4}/src/fleche.egg-info/SOURCES.txt +4 -0
  28. {fleche-0.22.2 → fleche-0.22.4}/src/fleche.egg-info/requires.txt +1 -1
  29. {fleche-0.22.2 → fleche-0.22.4}/src/fleche.egg-info/scm_file_list.json +4 -0
  30. fleche-0.22.4/src/fleche.egg-info/scm_version.json +8 -0
  31. {fleche-0.22.2 → fleche-0.22.4}/tests/conftest.py +7 -0
  32. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_cache.py +9 -9
  33. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_operation_context_cache.py +1 -1
  34. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_redigest.py +4 -2
  35. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_size_limited.py +1 -1
  36. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/call/test_dehydrate.py +22 -27
  37. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/call/test_prepared_call.py +56 -1
  38. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/digest/test_digest.py +448 -118
  39. fleche-0.22.4/tests/unit/digest/test_hashing.py +46 -0
  40. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_fleche.py +24 -1
  41. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_query_iterator.py +15 -30
  42. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_thread_safe.py +29 -0
  43. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/test_pickle.py +1 -1
  44. fleche-0.22.2/.release-please-manifest.json +0 -3
  45. fleche-0.22.2/src/fleche/_pandas.py +0 -110
  46. fleche-0.22.2/src/fleche.egg-info/scm_version.json +0 -8
  47. {fleche-0.22.2 → fleche-0.22.4}/.claude/CLAUDE.md +0 -0
  48. {fleche-0.22.2 → fleche-0.22.4}/.claude/settings.json +0 -0
  49. {fleche-0.22.2 → fleche-0.22.4}/.github/dependabot.yml +0 -0
  50. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/benchmarks-main.yml +0 -0
  51. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/benchmarks.yml +0 -0
  52. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/ci-failure-summary.yml +0 -0
  53. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/claude.yaml +0 -0
  54. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/perf-triage.yml +0 -0
  55. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/pypi-publish.yml +0 -0
  56. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/reflow-agent-docs.yml +0 -0
  57. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/reflow_agent_docs.py +0 -0
  58. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/release-please.yml +0 -0
  59. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/rendernb.yml +0 -0
  60. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/ty.yml +0 -0
  61. {fleche-0.22.2 → fleche-0.22.4}/.github/workflows/updatebenchmarks.yml +0 -0
  62. {fleche-0.22.2 → fleche-0.22.4}/.readthedocs.yaml +0 -0
  63. {fleche-0.22.2 → fleche-0.22.4}/AGENTS.md +0 -0
  64. {fleche-0.22.2 → fleche-0.22.4}/LICENSE +0 -0
  65. {fleche-0.22.2 → fleche-0.22.4}/README.md +0 -0
  66. {fleche-0.22.2 → fleche-0.22.4}/agents/USAGE.md +0 -0
  67. {fleche-0.22.2 → fleche-0.22.4}/benchmarks/benchmark_digest.py +0 -0
  68. {fleche-0.22.2 → fleche-0.22.4}/benchmarks/benchmark_integration.py +0 -0
  69. {fleche-0.22.2 → fleche-0.22.4}/benchmarks/benchmark_storage.py +0 -0
  70. {fleche-0.22.2 → fleche-0.22.4}/benchmarks/compare_results.py +0 -0
  71. {fleche-0.22.2 → fleche-0.22.4}/benchmarks/profile_digest_types.py +0 -0
  72. {fleche-0.22.2 → fleche-0.22.4}/benchmarks/results.csv +0 -0
  73. {fleche-0.22.2 → fleche-0.22.4}/benchmarks/run_benchmarks.py +0 -0
  74. {fleche-0.22.2 → fleche-0.22.4}/benchmarks/utils.py +0 -0
  75. {fleche-0.22.2 → fleche-0.22.4}/docs/_static/custom.css +0 -0
  76. {fleche-0.22.2 → fleche-0.22.4}/docs/conf.py +0 -0
  77. {fleche-0.22.2 → fleche-0.22.4}/docs/dev/call_lifecycle.rst +0 -0
  78. {fleche-0.22.2 → fleche-0.22.4}/docs/dev/custom_digests.rst +0 -0
  79. {fleche-0.22.2 → fleche-0.22.4}/docs/dev/extending_destructurer.rst +0 -0
  80. {fleche-0.22.2 → fleche-0.22.4}/docs/dev/sql_test_backends.rst +0 -0
  81. {fleche-0.22.2 → fleche-0.22.4}/docs/dev/storage_hierarchy.rst +0 -0
  82. {fleche-0.22.2 → fleche-0.22.4}/docs/digests/digests_as_args.rst +0 -0
  83. {fleche-0.22.2 → fleche-0.22.4}/docs/digests/entry_points.rst +0 -0
  84. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/cache_sequence.svg +0 -0
  85. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/dedup_forest.svg +0 -0
  86. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/gen_diagrams.py +0 -0
  87. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/gen_sequence.py +0 -0
  88. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/remaining_depth.svg +0 -0
  89. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/special_cases.svg +0 -0
  90. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/storage_hierarchy.dot +0 -0
  91. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/storage_hierarchy.svg +0 -0
  92. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/storage_mro.dot +0 -0
  93. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/storage_mro.svg +0 -0
  94. {fleche-0.22.2 → fleche-0.22.4}/docs/figures/storage_sequence.svg +0 -0
  95. {fleche-0.22.2 → fleche-0.22.4}/docs/index.rst +0 -0
  96. {fleche-0.22.2 → fleche-0.22.4}/docs/installation.rst +0 -0
  97. {fleche-0.22.2 → fleche-0.22.4}/docs/notebooks/CacheStack.ipynb +0 -0
  98. {fleche-0.22.2 → fleche-0.22.4}/docs/notebooks/Caches.ipynb +0 -0
  99. {fleche-0.22.2 → fleche-0.22.4}/docs/notebooks/Destructuring.ipynb +0 -0
  100. {fleche-0.22.2 → fleche-0.22.4}/docs/notebooks/ExtraMethods.ipynb +0 -0
  101. {fleche-0.22.2 → fleche-0.22.4}/docs/notebooks/FiveMinuteTour.ipynb +0 -0
  102. {fleche-0.22.2 → fleche-0.22.4}/docs/notebooks/GettingStarted.ipynb +0 -0
  103. {fleche-0.22.2 → fleche-0.22.4}/docs/notebooks/SecureStorage.ipynb +0 -0
  104. {fleche-0.22.2 → fleche-0.22.4}/docs/notebooks/StorageBackends.ipynb +0 -0
  105. {fleche-0.22.2 → fleche-0.22.4}/docs/notebooks/TransferWorkflow.ipynb +0 -0
  106. {fleche-0.22.2 → fleche-0.22.4}/docs/storage/cache_stack.rst +0 -0
  107. {fleche-0.22.2 → fleche-0.22.4}/docs/storage/destructuring.rst +0 -0
  108. {fleche-0.22.2 → fleche-0.22.4}/docs/storage/security.rst +0 -0
  109. {fleche-0.22.2 → fleche-0.22.4}/docs/usage/tldr.rst +0 -0
  110. {fleche-0.22.2 → fleche-0.22.4}/notebooks/CacheStack.ipynb +0 -0
  111. {fleche-0.22.2 → fleche-0.22.4}/notebooks/Caches.ipynb +0 -0
  112. {fleche-0.22.2 → fleche-0.22.4}/notebooks/Destructuring.ipynb +0 -0
  113. {fleche-0.22.2 → fleche-0.22.4}/notebooks/ExtraMethods.ipynb +0 -0
  114. {fleche-0.22.2 → fleche-0.22.4}/notebooks/FiveMinuteTour.ipynb +0 -0
  115. {fleche-0.22.2 → fleche-0.22.4}/notebooks/GettingStarted.ipynb +0 -0
  116. {fleche-0.22.2 → fleche-0.22.4}/notebooks/SecureStorage.ipynb +0 -0
  117. {fleche-0.22.2 → fleche-0.22.4}/notebooks/StorageBackends.ipynb +0 -0
  118. {fleche-0.22.2 → fleche-0.22.4}/notebooks/TransferWorkflow.ipynb +0 -0
  119. {fleche-0.22.2 → fleche-0.22.4}/release-please-config.json +0 -0
  120. {fleche-0.22.2 → fleche-0.22.4}/setup.cfg +0 -0
  121. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/__init__.py +0 -0
  122. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/__main__.py +0 -0
  123. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/_attrs.py +0 -0
  124. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/caches.py +0 -0
  125. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/config.py +0 -0
  126. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/executor.py +0 -0
  127. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/metadata.py +0 -0
  128. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/query.py +0 -0
  129. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/remote.py +0 -0
  130. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/security.py +0 -0
  131. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/state.py +0 -0
  132. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/__init__.py +0 -0
  133. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/bagofholding_file.py +0 -0
  134. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/base.py +0 -0
  135. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/destructuring.py +0 -0
  136. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/file.py +0 -0
  137. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/memory.py +0 -0
  138. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/pickle_file.py +0 -0
  139. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/sql.py +0 -0
  140. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/thread_safe.py +0 -0
  141. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/storage/void.py +0 -0
  142. {fleche-0.22.2 → fleche-0.22.4}/src/fleche/wrapper.py +0 -0
  143. {fleche-0.22.2 → fleche-0.22.4}/src/fleche.egg-info/dependency_links.txt +0 -0
  144. {fleche-0.22.2 → fleche-0.22.4}/src/fleche.egg-info/top_level.txt +0 -0
  145. {fleche-0.22.2 → fleche-0.22.4}/tests/__init__.py +0 -0
  146. {fleche-0.22.2 → fleche-0.22.4}/tests/fixtures.py +0 -0
  147. {fleche-0.22.2 → fleche-0.22.4}/tests/integration/__init__.py +0 -0
  148. {fleche-0.22.2 → fleche-0.22.4}/tests/integration/test_hash_code_integration.py +0 -0
  149. {fleche-0.22.2 → fleche-0.22.4}/tests/integration/test_integration.py +0 -0
  150. {fleche-0.22.2 → fleche-0.22.4}/tests/integration/test_methods.py +0 -0
  151. {fleche-0.22.2 → fleche-0.22.4}/tests/integration/test_notebooks.py +0 -0
  152. {fleche-0.22.2 → fleche-0.22.4}/tests/integration/test_parallel_execution.py +0 -0
  153. {fleche-0.22.2 → fleche-0.22.4}/tests/integration/test_remote.py +0 -0
  154. {fleche-0.22.2 → fleche-0.22.4}/tests/integration/test_wrapper_query_integration.py +0 -0
  155. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/__init__.py +0 -0
  156. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_builtin_signature.py +0 -0
  157. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_1004.py +0 -0
  158. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_217.py +0 -0
  159. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_297.py +0 -0
  160. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_319.py +0 -0
  161. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_352.py +0 -0
  162. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_451.py +0 -0
  163. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_452.py +0 -0
  164. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_485.py +0 -0
  165. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_578.py +0 -0
  166. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_592.py +0 -0
  167. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_issue_691.py +0 -0
  168. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_sql_concurrent_save.py +0 -0
  169. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_sql_non_sqlite_backends.py +0 -0
  170. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_sql_table_uniqueness.py +0 -0
  171. {fleche-0.22.2 → fleche-0.22.4}/tests/regression/test_wrap_executor_cloudpickle_lock.py +0 -0
  172. {fleche-0.22.2 → fleche-0.22.4}/tests/smoke/__init__.py +0 -0
  173. {fleche-0.22.2 → fleche-0.22.4}/tests/smoke/test_optional_deps.py +0 -0
  174. {fleche-0.22.2 → fleche-0.22.4}/tests/strategies.py +0 -0
  175. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/__init__.py +0 -0
  176. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/__init__.py +0 -0
  177. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_cache_pool.py +0 -0
  178. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_cache_stack.py +0 -0
  179. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_expand_shrink.py +0 -0
  180. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_filter.py +0 -0
  181. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_gc.py +0 -0
  182. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_lazy_call.py +0 -0
  183. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_readonly_cache.py +0 -0
  184. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/caches/test_refreshing_cache.py +0 -0
  185. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/call/__init__.py +0 -0
  186. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/call/test_code_digest.py +0 -0
  187. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/call/test_matches.py +0 -0
  188. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/call/test_partial_binding.py +0 -0
  189. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/config/__init__.py +0 -0
  190. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/config/test_cache_from_config.py +0 -0
  191. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/config/test_cache_to_config.py +0 -0
  192. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/config/test_config.py +0 -0
  193. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/config/test_storage_to_config.py +0 -0
  194. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/digest/__init__.py +0 -0
  195. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/digest/test_attrs.py +0 -0
  196. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/digest/test_attrs_optional_dep.py +0 -0
  197. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/digest/test_digest_methods.py +0 -0
  198. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/digest/test_entry_points.py +0 -0
  199. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/__init__.py +0 -0
  200. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_bound_wrapper.py +0 -0
  201. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_dataclass_input.py +0 -0
  202. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_decorator_attributes.py +0 -0
  203. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_digest_args.py +0 -0
  204. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_futures.py +0 -0
  205. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_hash_code.py +0 -0
  206. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_ignore_digest.py +0 -0
  207. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_process_args.py +0 -0
  208. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_processpool.py +0 -0
  209. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_rerun.py +0 -0
  210. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_signature_binding.py +0 -0
  211. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_type_hints.py +0 -0
  212. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/fleche/test_workdir.py +0 -0
  213. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/metadata/__init__.py +0 -0
  214. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/metadata/test_metadata.py +0 -0
  215. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/__init__.py +0 -0
  216. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_bagofholding_file.py +0 -0
  217. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_destructuring_dataclasses.py +0 -0
  218. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_destructuring_storage.py +0 -0
  219. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_file_storage.py +0 -0
  220. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_mixins.py +0 -0
  221. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_operation_context.py +0 -0
  222. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_optional_deps.py +0 -0
  223. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_overwrite.py +0 -0
  224. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_pickle_file.py +0 -0
  225. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_secure_storage.py +0 -0
  226. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_short_digest.py +0 -0
  227. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_sql_digest_types.py +0 -0
  228. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_sql_evict.py +0 -0
  229. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_sql_pragmas.py +0 -0
  230. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_sql_query.py +0 -0
  231. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_sql_url_coercion.py +0 -0
  232. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_storage.py +0 -0
  233. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_transform.py +0 -0
  234. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/storage/test_void.py +0 -0
  235. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/test_cache_sticky.py +0 -0
  236. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/test_executor.py +0 -0
  237. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/test_main.py +0 -0
  238. {fleche-0.22.2 → fleche-0.22.4}/tests/unit/test_remote.py +0 -0
@@ -35,7 +35,12 @@ jobs:
35
35
  fetch-depth: 0
36
36
  - uses: astral-sh/setup-uv@v5
37
37
  if: needs.changes.outputs.run == 'true'
38
- - run: uv venv
38
+ # Pin the interpreter to the oldest Python allowed by requires-python
39
+ # (keep in sync with pyproject.toml). Without it, uv picks the runner's
40
+ # system Python, which drifts with the image (ubuntu-26.04 ships 3.14),
41
+ # and the floors may have no wheels there, so they get built from source.
42
+ # uv downloads a managed 3.11 if the image lacks one.
43
+ - run: uv venv --python 3.11
39
44
  if: needs.changes.outputs.run == 'true'
40
45
  # --resolution lowest-direct pins every direct dependency to the lowest
41
46
  # version its bound allows, exercising the floors declared in pyproject.
@@ -62,6 +62,29 @@ jobs:
62
62
  if-no-files-found: ignore
63
63
  retention-days: 90
64
64
 
65
+ executorlib:
66
+ needs: changes
67
+ if: needs.changes.outputs.run == 'true'
68
+ runs-on: ubuntu-latest
69
+ steps:
70
+ - uses: actions/checkout@v5
71
+ - name: Set up Python
72
+ uses: actions/setup-python@v6
73
+ with:
74
+ python-version: "3.12"
75
+ cache: pip
76
+ - name: Setup
77
+ shell: bash -l {0}
78
+ run: |
79
+ pip install -e .[tests,executorlib]
80
+ - name: Run executorlib tests
81
+ shell: bash -l {0}
82
+ env:
83
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
84
+ run: |
85
+ # The main build job doesn't install executorlib, so it skips these.
86
+ pytest tests/integration/test_parallel_execution.py -k executorlib -v -rs
87
+
65
88
  sql-backends:
66
89
  needs: changes
67
90
  if: needs.changes.outputs.run == 'true'
@@ -11,3 +11,7 @@ notebooks/*.db-shm
11
11
  notebooks/*.db-wal
12
12
  docs/_build
13
13
  src/fleche/_version.py
14
+ .coverage
15
+ .coverage.*
16
+ coverage.xml
17
+ htmlcov/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.22.4"
3
+ }
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.22.4](https://github.com/pmrv/fleche/compare/0.22.3...0.22.4) (2026-11-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **call:** keep an explicit __version__ when the module cannot be imported ([#1028](https://github.com/pmrv/fleche/issues/1028)) ([f47005c](https://github.com/pmrv/fleche/commit/f47005c572d6c5eed65f6a28fab96b58bebef815))
9
+
10
+ ## [0.22.3](https://github.com/pmrv/fleche/compare/0.22.2...0.22.3) (2026-11-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **digest:** digest numbers by exact value through a downcast tree ([#1020](https://github.com/pmrv/fleche/issues/1020)) ([4daccb3](https://github.com/pmrv/fleche/commit/4daccb3f34bf9f6b87620105a1f7e8bfde11155f))
16
+
3
17
  ## [0.22.2](https://github.com/pmrv/fleche/compare/0.22.1...0.22.2) (2026-10-23)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleche
3
- Version: 0.22.2
3
+ Version: 0.22.4
4
4
  Summary: Hierachical, persistant caches in python
5
5
  Author-email: Marvin Poul <pmrv@posteo.de>
6
6
  License-Expression: BSD-3-Clause
@@ -43,7 +43,7 @@ Requires-Dist: attrs>=21.3; extra == "tests"
43
43
  Provides-Extra: executorlib
44
44
  Requires-Dist: executorlib; extra == "executorlib"
45
45
  Provides-Extra: ty
46
- Requires-Dist: ty==0.0.89; extra == "ty"
46
+ Requires-Dist: ty==0.0.95; extra == "ty"
47
47
  Dynamic: license-file
48
48
 
49
49
  # fleche
@@ -130,11 +130,11 @@ Values and calls are stored separately so call records are queryable without des
130
130
  `Hook`, `add_hook`, `get_hooks`, `load_entry_points`;
131
131
  `Indigestible`;
132
132
  `DIGEST_LENGTH=64`.
133
- Built-in types are inline `match` cases in `_digest_bytes`, ordered roughly hot-path first: `int` (`bool ⊂ int`) → `Digest` (returns `value.encode()` so `digest(Digest("abc")) == "abc"`) → `str` → `None` → `Number` (`float`, `complex`, `Decimal`, `Fraction`, plus `np.integer`/`np.floating` which satisfy `numbers.Number` automatically;
134
- routed through `hash(value)` then recursed as int — a lossy encoding under which distinct numbers collide;
135
- see the numeric-digest bullet under **Bugs** below) → `bytes` → `np.ndarray` (dtype + shape + `tobytes`;
136
- structured dtypes instead digest each field name + field view recursively, because `dtype.str` is only `|V<itemsize>` and padding bytes are uninitialised;
137
- other `dtype.hasobject` arrays digest every element of `ravel().tolist()` because their bytes are PyObject pointers) → `np.bool_` (delegates to `digest(bool(value))`, because `np.bool_ ∉ Number` since numpy 1.20) → pandas (`case _ if _pandas.is_pandas(value)`, which returns `_pandas.digest_pandas(value, _digest_bytes)`: `pd.DataFrame` / `pd.Series` / `pd.Index` / `pd.NA`;
133
+ Built-in types are inline `match` cases in `_digest_bytes`, ordered roughly hot-path first: `int` (`bool ⊂ int`) → `Digest` (returns `value.encode()` so `digest(Digest("abc")) == "abc"`) → `str` → `None` → `datetime.timedelta` / `np.timedelta64` (must precede `Number`, as numpy registers `timedelta64` `Integral`;
134
+ refused with `Indigestible` until a digest honours their cross-type, cross-unit `==` — the old `total_seconds()` float merged unequal `pd.Timedelta` nanoseconds;
135
+ `datetime.timezone` digests its offset as exact microseconds instead) → `Number` (`float`, `complex`, `Decimal`, `Fraction`, plus `np.integer`/`np.floating` which satisfy `numbers.Number` automatically;
136
+ handed to `_numbers.digest_number(value, _digest_bytes)`, see `_numbers.py` below, and `Indigestible` when it returns `None`;
137
+ This replaced `hash(value)`, a lossy encoding under which distinct numbers collided) → `bytes` → numpy (`case _ if _numpy.is_numpy(value)`, which returns `_numpy.digest_numpy(value, _digest_bytes)`: `np.ndarray` and `np.bool_`, see `_numpy.py` below) → pandas (`case _ if _pandas.is_pandas(value)`, which returns `_pandas.digest_pandas(value, _digest_bytes)`: `pd.DataFrame` / `pd.Series` / `pd.Index` / `pd.NA`;
138
138
  must precede `Iterable` — DataFrame iterates over column names only, so the `Iterable` arm collides any two DataFrames with the same columns regardless of values;
139
139
  DataFrame folds in columns + per-column dtype + index, Series folds in name + dtype + index, Index folds in name + dtype;
140
140
  values are mixed via `pd.util.hash_pandas_object(...).values.tobytes()` — `index=False` on the DataFrame/Series arms (the Index arm passes no kwarg since Index has no index) because `hash_pandas_object` only mixes per-element value bytes and ignores name/dtype/index metadata, so we hand-roll those.
@@ -161,13 +161,45 @@ Values and calls are stored separately so call records are queryable without des
161
161
  bound methods are their own arm (`types.MethodType`: receiver + underlying function, so `obj1.m` and `obj2.m` differ;
162
162
  a class receiver is named via `digest_class`), so there is no second entry point — `call._code_digest` reduces a bound method to `__func__` (the receiver already arrives as a call argument) and goes through `digest`;
163
163
  `digest_class(cls)` names a class `module.QualName` and is shared by the built-in-`type` arm and the `__class__` cell.
164
- `_new_hash(salt)` is the one place a salted running hash is started, apart from `_pandas.digest_pandas`, which mirrors it.
165
- NaN floats are packed via `struct.pack` (sign-preserving) instead of going through `hash()`;
166
- complex NaNs use `<dd`.
164
+ `_new_hash(salt)` (imported from `_hashing.new_hash`) starts every salted running hash.
165
+ - `_hashing.py` — the hash primitive, kept out of `digest.py` so the arm modules it imports (`_numbers`, `_numpy`, `_pandas`) can share it without importing it back.
166
+ `new_hash(salt)` starts a salted SHA256;
167
+ `running_hash(salt)` is a context manager over it whose `.result` is the finished digest in `_digest_bytes`'s wire format (hex as bytes) once the block exits, and raises `RuntimeError` if read earlier, so an arm module never spells out `hexdigest().encode()` itself.
168
+ The primitive lives only here and the encoding only here and in `digest.py`'s own `hexdigest()` returns, never in an arm module: the #440 raw-bytes switch touches those, and patching `_hashing.hashlib` swaps every digest's hash (`test_redigest`, `test_hashing`).
169
+ `running_hash` costs about 0.3 µs per use, so `digest.py`'s own hot path keeps the bare `_new_hash` + `hexdigest()`.
170
+ - `_numbers.py` — the number digest arm, kept out of `digest.py`;
171
+ `digest_bytes` is passed in because `digest.py` imports it;
172
+ it hashes through `_hashing.running_hash` (`_salted`), never `hashlib` directly.
173
+ `digest_number` walks `NUMBER_TREE` (`int` → {`numbers.Integral`, `float` → {`complex` → `numbers.Complex`, `Fraction` → {`Decimal`, `numbers.Real`}}};
174
+ a complex with zero imaginary part first drops to its real part) from the root down to the value's own node and digests it as the first type whose converter holds it exactly, so equal values digest alike unless they sit on different branches below that type (none do in the current tree).
175
+ A value's node is the most derived tree type its type subclasses (`_path`, cached per type), registered ABCs included: a type in another's MRO is always its superclass, so one `issubclass` rule covers real subclasses and numpy scalars, which are only *registered* with the `numbers` ABCs — `np.int64` lands on `Integral`,
176
+ `np.float32`/`np.longdouble` on `Real` (below `float` and `Fraction`),
177
+ `np.complex64` on `Complex`, while `np.float64`/`np.complex128` subclass `float`/`complex`.
178
+ `_hold(value)` returns the node that holds a value and the converted value;
179
+ `test_branch_choice_follows_exact_value` checks its choice against exact arithmetic, since the converters go through numpy for numpy scalars.
180
+ `np.float32`/`np.longdouble` on `Real` (below `float` and `Fraction`), `np.complex64` on `Complex`, while `np.float64`/`np.complex128` subclass `float`/`complex`.
181
+ ABC nodes have no converter or encoder: they only place what sits under them, and `Decimal` never holds a value either (every finite `Decimal` is an exact `Fraction`).
182
+ A value nothing on its path holds gets `None`, which `digest.py` raises as `Indigestible`.
183
+ Converters must refuse inexact conversions themselves rather than rely on a trailing `==`: numpy compares `Fraction` to `np.longdouble` by rounding the `Fraction` to double, and an `np.longdouble` to an `int` through `str` (subject to `sys.int_max_str_digits`).
184
+ So `_as_int` decides on `as_integer_ratio` / `numerator`+`denominator` (also avoiding a quadratic `int()` round trip for `Decimal("1e1000000")`), falling back to `math.trunc` for a foreign `Real`, which promises `__trunc__` but neither `__int__` nor `as_integer_ratio`;
185
+ Encoders salt with the bare type name of the node (`_salted(float, …)`), like every generic arm;
186
+ a user type sharing the name and feeding the same bytes would collide, which is accepted as for all other types.
187
+ `_is_nan` skips `Rational`s and treats `OverflowError` as not-NaN, since `cmath.isnan` converts to float and `Fraction(10**400)` overflows it.
188
+ The promise, worded in `digest_equivalence.rst`: non-NaN numbers digest equal iff they hold the same exact value — numpy's `==` (NEP 50 down-casts, `Fraction` rounding) is not the reference.
189
+ `int` subclasses (`bool`, `IntEnum`) never reach the tree: the `int` arm catches them under their own type name, unlike `float` subclasses, which downcast.
190
+ The tree is drawn in the code comment and in `docs/digests/digest_equivalence.rst`;
191
+ no test checks the drawings, so update both by hand whenever `NUMBER_TREE` changes.
192
+ A NaN is digested by its bits exactly as the `float` (or, with an imaginary part, `complex`) node would encode them, whatever its type, so `np.float64("nan")` meets `float("nan")` and the sign survives;
193
+ `Decimal` NaNs (including `sNaN`, which `cmath.isnan` rejects) by `str()` under the `Decimal` salt.
194
+ Equal-value results of different numeric types share one value-storage entry, and whichever saved last wins the return type — intended, not a bug.
167
195
  - `_attrs.py` — Internal `is_attrs_instance` / `field_items` helpers gating optional `attrs` support;
168
196
  safe to import without `attrs` installed.
197
+ - `_numpy.py` — the `np.ndarray` and `np.bool_` digest arm (`is_numpy` / `digest_numpy(value, digest_bytes)`), plus `is_timedelta64` for `digest.py`'s timedelta refusal, so `digest.py` no longer imports numpy;
198
+ arrays digest dtype + shape + `tobytes`, structured dtypes each field name + field view recursively (`dtype.str` is only `|V<itemsize>` and padding bytes are uninitialised), other `dtype.hasobject` arrays every element of `ravel().tolist()` (their bytes are PyObject pointers);
199
+ `np.bool_` delegates to the builtin `bool` (`np.bool_ ∉ Number` since numpy 1.20).
200
+ numpy *number* scalars are not handled here but by `_numbers`.
169
201
  - `_pandas.py` — Internal digest arms for pandas objects: `is_pandas` gates the one pandas case in `_digest_bytes`,
170
- `digest_pandas` digests DataFrame / Series / Index / MultiIndex / `pd.NA` with the same salt and hex return as `_digest_bytes`, and `needs_element_digests` / `element_digests` handle object columns (#1004);
202
+ `digest_pandas` digests DataFrame / Series / Index / MultiIndex / `pd.NA` with the same bare-type-name salt as `_digest_bytes`, through `_hashing.running_hash`, and `needs_element_digests` / `element_digests` handle object columns (#1004);
171
203
  `digest_pandas` and `element_digests` take `digest._digest_bytes` as an argument, since `digest.py` imports the module.
172
204
  - `call.py` — `Call` dataclass (`from_call`, `to_lookup_key`, `.stash(values)→DigestedCall` writes args+result to value storage / `.digest()→DigestedCall` digests without saving);
173
205
  `DigestedCall` (storage representation: arguments and result are `Digest` keys;
@@ -404,7 +436,7 @@ Values and calls are stored separately so call records are queryable without des
404
436
  best-effort — non-Linux or an unreadable `/proc/mounts` reports "not network";
405
437
  network types are `nfs`/`nfs4`/`cifs`/`smb*`/`ceph*`/`glusterfs`/`9p`/`lustre`/`gpfs`/`panfs`/`afs`/`afpfs`;
406
438
  a warning is logged when WAL is skipped).
407
- `:memory:` skips `journal_mode` entirely.
439
+ `:memory:` skips `journal_mode` entirely — the check is `engine.url.database in (None, ":memory:")`, because SQLAlchemy percent-encodes `:memory:` when rendering the URL, so a string match on the rendered URL never fires (a dead short-circuit until PR #980 fixed it).
408
440
  The schema is three tables: `calls`, `arguments` (one row per arg, `UNIQUE(call_key, name)`, ordered by `position`), `metadata` (one JSON blob per metadata namespace, `UNIQUE(call_key, name)`).
409
441
  `arguments.call_key`/`metadata.call_key` are FKs with `ondelete="CASCADE"`, so `_evict` is a single `session.execute(delete(CallModel).where(...))` (SQLAlchemy DSL, no raw SQL or ORM materialisation) and lets the DB clean up child rows (SQLite needs the `foreign_keys=ON` PRAGMA the dialect listener installs;
410
442
  Postgres/MySQL enforce FK cascades natively).
@@ -757,6 +789,18 @@ Git history is the changelog.
757
789
  Planned refactor moves built-in type handlers into an internal dispatch registry that mirrors the user `Hook` list;
758
790
  composes with #834.
759
791
 
792
+ - **Enum digests** (#1022, survey filed 2026-10-24 — nothing decided;
793
+ probed on the #1020 branch).
794
+ Current behaviour: plain `Enum` members are `Indigestible`;
795
+ `IntEnum`/`StrEnum`/mixin members digest through their mixin's arm under the bare class `__name__` (two same-named classes sharing a value collide;
796
+ float/`Decimal` mixins go through the number logic).
797
+ Two bugs the survey found: `Flag` members — and `Flag` classes passed as values — raise `RecursionError` through the `Iterable` arm on 3.11+ (a Flag member iterates and yields itself;
798
+ `wrapper.py` only falls back on `Indigestible`, so a decorated `f(Perm.R)` *raises* instead of running uncached), and destructuring storage mishandles mixin enums (a `tuple`-mixin member fails `ValueMemory.save` with `ValueError`;
799
+ a dataclass-mixin member loads back as a bogus non-member).
800
+ Designs surveyed: digesting the full `{member_name: value}` mapping makes every member's digest depend on the whole class (drifts across Python versions — `http.HTTPStatus` has 62 members on 3.11, 66 on 3.13 — one indigestible sibling poisons all members, ~50× cost);
801
+ the leaner alternative digests class `__name__` + canonical member name (`None` for Flag pseudo-members) + `_value_` under one enum salt, placed before the whole `match` (float/tuple/date/dataclass mixins), trading away rename-relocatability.
802
+ Any enum arm changes `IntEnum`/`IntFlag`/`StrEnum`/mixin digests, and relocation only ever helps arguments — a stored result still unpickles through the old module path.
803
+
760
804
  **Open refactor proposals** (small, well-scoped, no PRs yet)
761
805
 
762
806
  *Source:*
@@ -876,24 +920,14 @@ Git history is the changelog.
876
920
  Composes with #883/#905 and #333.
877
921
  - #883 — `DestructuringMixin._raw_sub_digests` hardcodes a `match` over the three built-in `Digested` types, so a subclass registered via `register_destructurer` reports zero children: `Cache.gc()` can evict values reachable only through it (silent data loss) and `count_reuses()` under-counts.
878
922
  Fix in flight as PR #905 (below).
879
- - #1004 — digesting a DataFrame with an object column of numpy arrays raises at save time: `hash_pandas_object` calls `factorize`, which needs `hash()`-able elements.
880
- Worse, every object column that does *not* raise is hashed through `str()`, so `["a", 1]` collides with `["a", "1"]`, equal-`__str__` objects collide, and `Series([object()])` keys on an address that changes per process;
881
- `MultiIndex` levels have the same `str()` weakness, and level names and level dtypes are never folded in at all.
882
- Fix in flight as PR #1006 (below);
883
- maintainer direction on the issue: be strict (`Indigestible` beats a `str()` fallback), fix the MultiIndex in the same go, and "different tables hash differently" is the main priority.
884
-
885
- - Numeric digest collisions — no issue filed yet;
886
- the full investigation (2026-10-06) lives in a comment on PR #990.
887
- The `Number` arm digests via `hash(value)`, a lossy 61-bit residue, so distinct numbers share cache keys and produce wrong hits end to end: `digest(-1.0) == digest(-2.0)` (CPython reserves `-1` as an error sentinel),
888
- `digest(0.5) == digest(2**60)` (rational hashing mod `2**61 - 1`),
889
- `inf` vs `314159`,
890
- `np.uint64(2**63)` vs `4`, complex via the `hash(a) + 1000003*hash(b)` formula;
891
- digests are also platform-dependent (32-bit builds hash mod `2**31 - 1`).
892
- The Hypothesis pair tests in `digest/test_digest.py` can't draw these coincidences, and the complex test's oracle restates the bug (`hash(x) == hash(y)` ⇒ equal digests) — regression tests need explicit `@example`s.
893
- A second, separable finding: `digest(3) == digest(3.0)` by design, so equal-value different-type *results* share one value-storage entry and whichever saved last wins the return type.
894
- The PR comment carries a validated `_digest_number` prototype (digest the exact rational via `as_integer_ratio`, dedicated salts for rational/complex/inf, NaN keeps `struct.pack`;
895
- full suite green) pending maintainer decisions on unknown `Number` types (raise `Indigestible` vs keep `hash()`) and on result-type erasure.
896
- The fix invalidates number-bearing keys — pre-1.0 that is a `fix:` per [Commit messages](#commit-messages), and it is worth coordinating with the blake2b switchover #615, which invalidates every key anyway.
923
+ - `test-minimum-deps` is red on every run since 2026-10-23, also with no repo change: `ubuntu-latest` moved from ubuntu-24.04 (Python 3.12) to ubuntu-26.04 (Python 3.14, gcc 15), the job's bare `uv venv` uses that system Python, and `--resolution lowest-direct` selects pandas 2.2.0, which has no cp314 wheel and whose Cython 3.0.5 output does not compile under gcc 15.
924
+ The fix is in flight as PR #1026 (below): `uv venv --python 3.11`, the `requires-python` floor, so the job tests the dependency floors on the oldest supported Python and stops drifting with the runner image.
925
+ No issue filed yet.
926
+ Until it merges, a red `test-minimum-deps` on a `src/`-touching PR is likewise not that PR's failure.
927
+ Running the job on uv's managed 3.11 exposed a real cache-correctness bug, fixed in flight as PR #1028 (below): when `VersionInfo.of` raises `ModuleNotFoundError`,
928
+ `FunctionProfile.of`'s fallback set `version = None`, dropping the function's own `__version__` (the one `fleche(version=...)` sets) — so two versions of a function from an unimportable module shared one cache key and the second was served the first one's result.
929
+ Merge order: #1028 first, then rebase #1026 (its `test_fleche_with_version` failure under uv's Python — whose standalone builds ship no stdlib `test` package, so the test's `__module__ = "test"` import genuinely fails — needs the fix).
930
+
897
931
 
898
932
  **Feature requests**
899
933
 
@@ -906,6 +940,13 @@ Git history is the changelog.
906
940
 
907
941
  **Field reports**
908
942
 
943
+ - #1019 — config lookup misses inside executorlib *file-backend* jobs (`SlurmClusterExecutor`, `TestClusterExecutor`) when `cache_directory` sits behind a symlink: the job chdirs into its task folder,
944
+ `getcwd()` returns the physical path, the upward `fleche.toml` walk never reaches the project directory, and fleche silently (INFO-level) falls back to a transient memory cache.
945
+ Inside an MPI/collective job the miss then recomputes on *every rank*.
946
+ Workarounds that hit: `wrap_executor` in the driver (the `BoundWrapper` carries the cache config into the payload), or resolving cached values in the driver and passing them in — the latter is the only option that also avoids per-rank recompute on a genuinely cold call.
947
+ Asks in the issue: document the collective-job pattern, make the fallback loud (WARN/raise when a parent had a persistent cache), and a `FLECHE_CONFIG`-style explicit config location (prototype on local branch `claude/fleche-config-env-var`, not pushed;
948
+ related to #868 and #568).
949
+
909
950
  - #942 — mine it before building anything in this area: migrating ~20k cached calls across a module rename under the default `hash_module=True`.
910
951
  The call index was rewritten via `dataclasses.replace(digested_call, module=new)` + `.to_lookup_key()` (reusing fleche's own key derivation), run add-only first against a backup copy, verified by a recompute-counter test (a `contains()` check would pass on a silent recompute), then `--replace` + `VACUUM` (SQLite does not shrink on delete).
911
952
  Value storage is untouched — a module rename changes call keys only.
@@ -915,20 +956,60 @@ Git history is the changelog.
915
956
 
916
957
  - #887 — `BoundWrapper.__reduce__` pre-serialises `func` into a standalone bytes payload (plain `pickle` by reference when importable, `cloudpickle` by value otherwise, a clear `TypeError` when cloudpickle is missing), so it survives `spawn`/`forkserver` pools;
917
958
  closes #840, regression pin `tests/regression/test_issue_840.py`.
918
- - #1006 — digests pandas object columns that pandas cannot hash faithfully with fleche's own per-element hash (anything `infer_dtype` doesn't label `"string"`/`"empty"`, including `bytes` columns and object categories), handing `hash_pandas_object` the column of hex digests;
919
- elements fleche cannot digest raise `Indigestible` (strict), plus a new `pd.NA` arm;
920
- closes #1004.
921
- The 2026-10-19 review round reworked #1006 in place rather than replacing it with the once-superseding branch (`claude/issue-1004-20261013-1959`, never opened as a PR): the pandas arms moved to a new `fleche._pandas` module (`digest.py` no longer imports pandas), a `MultiIndex` arm was added (tuple of its levels, names included — every `MultiIndex` digest changes), and `hash_pandas_object` stays on the main path for faithful columns, so only object-column and `MultiIndex` keys change (migratable via `Cache.redigest()`).
922
- The 2026-10-20 round dropped the `__fleche_own_hashed__` marker — no marker records the swap, so a column of arrays digests like the column of its elements' `Digest`s, the Merkle property lists already have — and renamed the helpers to `needs_element_digests`/`element_digests`.
923
- - #1014 (opened 2026-10-21) — fleche-hosted Postgres/MariaDB call storage with autostart: `calls.type = "postgres"` / `"mariadb"` plus `calls.datadir` (resolved relative to the declaring `fleche.toml`, like `root`) in config;
924
- with `calls.autostart = true` the first process that finds nothing listening sets up `datadir` and launches `python -m fleche db …` detached (a `FileLock` on `<datadir>.lock` means racing processes wait for the winner, so exactly one server starts, and it outlives the process).
925
- Setup creates database and user `fleche` with a random password and a free port;
926
- the URL is written back as `url` into the declaring `fleche.toml` via tomlkit (comments survive), and a readable `DATADIR/fleche.url` takes precedence over it;
927
- to support the write-back, the config loader now records which file each top-level table came from.
928
- New CLI `python -m fleche db {postgres,mariadb} DATADIR` runs the server in the foreground (`--cache NAME` records the server into config, refusing in-memory values, stacks with several persistent layers, pools and SSH caches) and `python -m fleche db stop DATADIR` shuts it down;
929
- `serve` writes host+pid to `DATADIR/fleche.server` before starting, and autostart refuses a datadir another host is serving (shared filesystems).
930
- New extras `fleche[postgres]` / `fleche[mariadb]` (sqlalchemy + driver + tomlkit).
959
+ - #1014 / #1017 / #1018 (opened 2026-10-21/22) — fleche-hosted Postgres/MariaDB call storage, a stack of three PRs, each based on the previous.
960
+ **#1014** (base): new CLI `python -m fleche db {postgres,mariadb} DATADIR` sets up `DATADIR` on first run (database and user `fleche`, random password kept in `DATADIR/fleche.url` mode 0600, a free port remembered across restarts) and runs the server in the foreground;
961
+ `python -m fleche db stop DATADIR` stops it via the host+pid that `serve` records in `DATADIR/fleche.server`.
962
+ Config side: `calls.type = "postgres"` / `"mariadb"` plus `calls.datadir` (resolved relative to the declaring `fleche.toml`, like `root`) builds a plain `Sql` on the URL from `DATADIR/fleche.url`, so the password is never copied;
963
+ nothing listening raises an error naming the start command.
964
+ New extras `fleche[postgres]` / `fleche[mariadb]`;
965
+ URLs name their driver explicitly because SQLAlchemy 2.1 changed what driver a bare `postgresql://` selects.
931
966
  Rides with two `Sql` concurrency fixes hit with 8 writers on a fresh server database — `create_all`'s check-then-create schema race retries, and `save` retries deadlock-aborted transactions (MariaDB error 1213, Postgres SQLSTATEs `40P01`/`40001`) — and a test-hygiene fix (`_test_missing_dep` now restores the storage registry it used to leave broken for later tests).
967
+ **#1017** (on #1014): `calls.autostart = true` — when nothing is listening, the first process launches `python -m fleche db …` detached (own session, logging to `<datadir>.log`) and waits until ready;
968
+ a `FileLock` on `<datadir>.lock` makes racing processes wait for the winner, so exactly one server starts, and it outlives the process.
969
+ Autostart never *creates* a database — a missing or uninitialized datadir is a `DbServerError` naming the setup command — and refuses a datadir another host is serving (shared filesystems).
970
+ **#1018** (on #1017): the URL is written back as `url` into the declaring `fleche.toml` via tomlkit (comments survive;
971
+ the config loader records which file each top-level table came from to support this), and `db --cache NAME` records the server into config (refusing in-memory values;
972
+ stacks with persistent layers, pools and SSH caches).
973
+ - #1020 (opened 2026-10-23) — fixes the numeric digest collisions found on PR #990 (`hash()` made `digest(-1.0) == digest(-2.0)` and `digest(0.5) == digest(2**60)`): `_digest_bytes`'s `Number` arm stops routing through lossy `hash()` and instead digests each number as the first type on an explicit downcast tree (`int → float → {complex, Fraction → Decimal}`) that holds its exact value, so non-NaN numbers digest equal **iff** they are the same exact value (`1 == 1.0 == 1+0j` as `int`;
974
+ `0.5 == Fraction(1, 2) == Decimal("0.5")` as `float`), with converters deciding exactness themselves (`as_integer_ratio`, `math.trunc`) rather than trusting numpy's NEP-50 `==`;
975
+ a `Number` nothing on its path holds exactly raises `Indigestible`, never `hash()`.
976
+ NaNs digest by their bits, whatever their type;
977
+ timedeltas (`datetime.timedelta`, `pd.Timedelta`, `np.timedelta64`) now raise `Indigestible` until one digest can honour cross-type equality without unit loss (`total_seconds()` collided distinct `pd.Timedelta`s) — PR #1023 (below), stacked on this one, carries that digest;
978
+ `datetime.timezone` digests its offset as exact microseconds.
979
+ Also moves the number arm into a private `fleche._numbers` module and the numpy arms into `fleche._numpy` (the `_pandas` pattern), and extracts a shared `fleche._hashing` module (`new_hash(salt)` + a `running_hash` context manager) that all three use.
980
+ Persisted keys involving non-integral numbers, `±inf`, complex, huge ints, NaNs or timezones change (`Cache.redigest()` migrates;
981
+ plain `int` keys are unchanged).
982
+ Supersedes #990 (closed unmerged 2026-10-24;
983
+ the numeric-digest investigation referenced under **Bugs** stays readable in its review comments);
984
+ overlaps #1000's NaN-arm pins, so one of the two needs a rebase.
985
+ - #1023 (opened 2026-10-24, stacked on #1020 — retargets to `main` once that merges) — digests each library's timedeltas by **exact length**, lifting #1020's `Indigestible` refusal: `datetime.timedelta` as exact microseconds (beside the other datetime arms in `digest.py`),
986
+ `pd.Timedelta` as exact nanoseconds via `to_timedelta64()` (`.value` would overflow;
987
+ the arm sits in `_pandas` ahead of the `datetime.timedelta` arm it subclasses),
988
+ `np.timedelta64` as exact attoseconds for every fixed unit,
989
+ `W` to `as` (in `_numpy`, ahead of `Number` — numpy registers it as `Integral`).
990
+ Libraries deliberately digest apart even where they compare equal (merging would force a unit-lossy common denominator), units within one library merge, and a timedelta never digests like a number;
991
+ `Y`/`M` digest as months, the unitless `np.timedelta64(5)` as its count, NaT alike in every unit — numpy's cross-unit `==` can wrap int64, so it is not the reference.
992
+ `datetime.timezone` digests its offset through the `datetime.timedelta` arm;
993
+ timedelta64 *arrays*/Series are unchanged (dtype + bytes).
994
+ `np.datetime64` stays `Indigestible`.
995
+ - #1024 (opened 2026-10-24) — Python 3.15 support: `requires-python` → `>=3.11,<3.16`, 3.15 in the `tests.yml` matrix with `allow-prereleases: true`.
996
+ The one real breakage: on 3.15 `threading.RLock` is a plain function, so `_factory = threading.RLock` bound as a method and `_PicklableRLock()` raised `TypeError` — took down every `SerializingMixin` storage and `SizeLimitedCache` (~36 failures);
997
+ the lock factories are now real `@staticmethod`s.
998
+ The 3.15 job builds h5py from source (no cp315 wheels yet;
999
+ drop that step once wheels publish) and is **blocked**: `pyiron-snippets` and `bagofholding` both declare `Requires-Python <3.15` on every release, so pip refuses them on 3.15 (options in a PR comment).
1000
+ Also carries the `ty` fix for the pandas-3.1 `pd.util` regression (fixed by #1020) as its own commit, and moves `test_fleche_with_version` off `__module__ = "test"` (only resolves when the stdlib `test` package is installed — uv's standalone builds leave it out).
1001
+ - #1026 (opened 2026-10-25) — pins `test-minimum-deps`'s venv to Python 3.11 via `uv venv --python 3.11` (see **Bugs**: the job is red since the runner image moved to ubuntu-26.04, whose system Python 3.14 has no pandas 2.2.0 wheel and fails its source build under gcc 15);
1002
+ keep the pin in sync with the `requires-python` floor.
1003
+ The rejected alternative was raising the numpy/pandas floors for every user just to get cp314 wheels;
1004
+ the 3.12–3.14 interpreters stay covered with current deps by the `tests.yml` matrix.
1005
+ Blocked on #1028 (below) — merge that first, then rebase.
1006
+ - #1028 (opened 2026-10-28) — `FunctionProfile.of` keeps an explicit `__version__` when the function's module cannot be imported: the `ModuleNotFoundError` fallback in `src/fleche/call.py` set `version = None` instead of `getattr(func, "__version__", None)`, so two versions of a function from an unimportable module shared one cache key (see **Bugs**).
1007
+ Surfaced by #1026's uv-managed Python, where `test_fleche_with_version`'s mock module name `"test"` stops resolving (uv's standalone CPython ships no stdlib `test` package) and the fallback actually runs.
1008
+ New regression test uses a module name that never exists, so it covers the fallback on every interpreter.
1009
+ Its own `test-minimum-deps` check stays red until #1026's pin lands — the two merge in that order: #1028, then #1026 rebased.
1010
+ - #1032 (dependabot, opened 2026-11-04, replacing #1030, closed unmerged the same day) — bumps the pinned `ty` type-checker (the `ty` extra, which the CI job installs) 0.0.89 → 0.0.95.
1011
+ Its `ty` run fails with the same three pandas-3.1 `call-non-callable` diagnostics as `main` before #1020, so the bump neither causes nor clears the known red;
1012
+ `test-minimum-deps` is red as on every PR until #1026 lands, and the 3.11–3.14 test matrix is green.
932
1013
  - #786 (draft) — a process-wide cache of read-only `h5py.File` handles for multi-bag files (bounded MRU of 32, `(inode, mtime_ns, size)` invalidation, `locking=False`, writers close the cached handle under the per-bag lock);
933
1014
  multi-bag `contains` drops from ~240 µs to ~29 µs.
934
1015
  `evict` deliberately untouched;
@@ -944,24 +1025,20 @@ Git history is the changelog.
944
1025
  Needs a rebase onto `tldr.rst`/`helpers.rst` as they are on `main`.
945
1026
  - #934 — SSH config + destructuring list.
946
1027
  Needs a rebase onto `ssh_cache.rst` as #993 left it.
947
- - #965 — usage: scope the lazy-return claim to `load()`/`query()`.
948
- - #989 — parallel_execution: an MPI-decorated call runs once per rank cold and serves one rank's record warm.
949
- - #990 — digests: the numeric-digest mechanism explanation;
950
- superseded by its own review — the maintainer judged the rewrite the wrong level ("warrants deeper investigation") and the follow-up investigation on the PR found the digest *code* at fault (see the numeric-digest bullet under **Bugs**), so expect it to close in favour of a code fix rather than merge.
951
1028
 
952
- #932 and #989 both edit `parallel_execution.rst`;
953
- whichever lands second needs a rebase.
1029
+ #990 (digests: the numeric-digest mechanism explanation) closed unmerged 2026-10-24 as superseded — its review investigation found the digest *code* at fault, and PR #1020 (above) carries the code fix plus the docs promise.
1030
+ #932 needs a rebase: #989 (merged 2026-10-22) changed `parallel_execution.rst` under it.
954
1031
  - Test-only coverage sweeps: #939 (pins the `Intent.READ` no-op fast path in both lock mixins;
955
- drops 11 tests strictly weaker than a sibling;
1032
+ drops tests strictly weaker than a sibling — rebased 2026-10-23, where three of its eight digest-smoke removals turned out to have already landed via the merged #980 and are now no-ops;
956
1033
  converts `test_local_function_digests_same_as_module_level` into `test_nesting_changes_a_function_digest` — `CO_NESTED` is in `co_flags`, so lifting a helper out of an enclosing function invalidates its cached calls under `hash_code=True`;
957
1034
  pinned as observed, flip it there if ruled a bug), #955 (pins the deferred-future abandon arm in `wrapper.py`;
958
- collapses five single-property tests in `test_dehydrate.py`), #980 (covers the `storage/sql.py` PRAGMA guards and fixes the dead in-memory journal-mode short-circuit — SQLAlchemy percent-encodes `:memory:`, so the check becomes `engine.url.database in (None, ":memory:")`;
959
- drops 11 vacuous digest smokes), #1000 (pins the NaN `struct.pack` arm deterministically — two *simultaneously live* same-value NaNs must share a digest, which `hash()`-routing would break since CPython's NaN hash is address-derived;
1035
+ collapses five single-property tests in `test_dehydrate.py`), #1000 (pins the NaN `struct.pack` arm deterministically — two *simultaneously live* same-value NaNs must share a digest, which `hash()`-routing would break since CPython's NaN hash is address-derived;
960
1036
  covers the `<d` `Decimal`-NaN sub-arm and the `np.bool_` → builtin-`bool` delegation;
961
1037
  narrows the Hypothesis float/complex pair tests to `allow_nan=False`, killing their self-fulfilling NaN oracle branches;
962
1038
  drops four digest tests subsumed *in assertion* by siblings, after a per-test-arc ablation confirmed 89% of tests have zero unique arcs — reaffirming the collective-redundancy lesson below;
963
1039
  also `.gitignore`s `pytest --cov`'s `.coverage` litter;
964
- the PR body flags the next deterministic-coverage picks, led by `bagofholding_file.py`'s seed-dependent `SaveError` arm).
1040
+ the PR body flags the next deterministic-coverage picks, led by `bagofholding_file.py`'s seed-dependent `SaveError` arm;
1041
+ rebased 2026-10-23 and re-measured — note PR #1020 replaces the NaN arm it pins, so one of the two needs a rebase).
965
1042
 
966
1043
  **Lessons and standing decisions**
967
1044
 
@@ -986,8 +1063,7 @@ Git history is the changelog.
986
1063
  an addon package (`fleche-mpi` or similar) is the preferred home, and the close-out comment on #870 is its reference.
987
1064
  Branch `claude/fleche-mpi-executor-caching-zxjazw` keeps the helper, 13 subprocess-isolated integration tests, and `docs/mpi_execution.rst`.
988
1065
  Findings that stand regardless: lookup keys carry no rank, so all ranks contend for one record — warm runs flip the `[result, None]` return shape, and divergent per-rank hit/miss decisions make some ranks skip the body's collectives (silently wrong, or a hang).
989
- What works today: decorate the *submission site*, not the MPI kernel.
990
- `wrap_executor`'s cold/warm return-shape asymmetry is still undocumented in `docs/parallel_execution.rst` (#989 adds the per-rank warning).
1066
+ User docs stay silent on MPI-native functions: fleche makes no promises either way, so #1037 removed the per-rank caveat #989 had added to `docs/parallel_execution.rst`, and the executorlib example uses task parallelism (`max_workers`) rather than `resource_dict={"cores": N}`.
991
1067
  - Optional skip-saving of input arguments (#170), corruption-tolerant loads (#93), method-state restoration (#99), metadata-into-value-storage (#82) — parked for milestone 2.0.
992
1068
  - Refactors flagged `backburner`: `_digest` dispatch registry (#333), `DigestedCall`/`LazyCall` consolidation (#404).
993
1069
  - Terminology shift "cache" → "stash" and unifying "hash"/"digest" on "digest" (#350) — targeted for 1.0, not started.
@@ -34,3 +34,8 @@ correctly without special-casing at call sites.
34
34
  ``FunctionProfile`` and populate it inside ``FunctionProfile.of``. Downstream
35
35
  code then reads it from the profile instead of calling introspection APIs
36
36
  directly.
37
+
38
+ See :doc:`/usage/helpers` ("Per-Function Static Caching") for the
39
+ user-facing behavior and caveats — including the "mutating ``func`` after
40
+ the first call isn't picked up" gotcha and how to clear the cache via
41
+ ``_profile.cache_clear()``.
@@ -4,9 +4,9 @@
4
4
  ======================
5
5
 
6
6
  This page is a tour of :mod:`fleche.remote` for contributors hacking on the
7
- cross-machine cache. End-user documentation lives in
8
- :doc:`/storage/configuration`; this page explains *how* the pieces fit
9
- together.
7
+ cross-machine cache. End-user configuration lives in
8
+ :doc:`/storage/configuration` (see its ``type = "ssh"`` section); this page
9
+ explains *how* the pieces fit together.
10
10
 
11
11
  Security model
12
12
  --------------
@@ -9,24 +9,77 @@ hold the same numeric value, and ``digest((1, 2)) != digest([1, 2])`` because th
9
9
  container type matters. This page documents the deliberate equivalences ``fleche``
10
10
  guarantees, and the boundaries you should keep in mind.
11
11
 
12
- The digest function is content-based: it walks each value, typically salts the
13
- running hash with ``type(value).__name__`` for type discrimination, and folds in a
14
- representation that is stable across processes and Python versions (where the
12
+ The digest function is content-based: it walks each value and folds in its type
13
+ and a representation that is stable across processes and Python versions (where the
15
14
  underlying object's representation allows it).
16
15
 
17
- Number types (``int``, ``float``, ``complex``, and subclasses) are an explicit
18
- exception: their digest is computed via Python's numeric hash protocol
19
- (``hash(value)``), which itself guarantees ``hash(1) == hash(1.0) == hash(1+0j)``.
20
- The type name is therefore **not** carried into the final digest for numbers, which is
21
- precisely why ``digest(1) == digest(1.0) == digest(1+0j)``.
22
-
23
- ``bool`` is the one numeric subclass excluded from this equivalence: it keeps
24
- its own type-name salt, so ``digest(True) != digest(1)`` even though
25
- ``isinstance(True, int)``.
26
-
27
- For all other types the type-name salt is applied normally, so
28
- ``digest((1, 2)) != digest([1, 2])`` because "tuple" and "list" diverge before
29
- the elements are processed.
16
+ Numbers
17
+ -------
18
+
19
+ Numbers digest by their value, not their type: ``1``, ``1.0``, ``1+0j`` and
20
+ ``Fraction(1)`` all digest alike. To find that value, ``fleche`` walks a fixed
21
+ tree of number types:
22
+
23
+ .. code-block:: text
24
+
25
+ int
26
+ ├── numbers.Integral
27
+ └── float
28
+ ├── complex
29
+ │ └── numbers.Complex
30
+ └── fractions.Fraction
31
+ ├── decimal.Decimal
32
+ └── numbers.Real
33
+
34
+ A number's place in the tree is its class or, for a type the tree does not
35
+ name, the :mod:`numbers` ABC the type belongs to; that is where numpy scalars
36
+ such as ``np.int64`` or ``np.float32`` sit. The digest walks from ``int`` down
37
+ to that place and tries to convert the number to each type it passes. The
38
+ first type that holds the number exactly digests it: ``0.5`` is no ``int`` but
39
+ an exact ``float``, and ``Fraction(1, 3)`` is neither, so it digests as a
40
+ ``Fraction``. A complex number with no imaginary part digests as its real
41
+ part. A number that no type on its path holds exactly is
42
+ :exc:`~fleche.digest.Indigestible` unless a hook handles it.
43
+
44
+ Two numbers therefore digest equal exactly when they hold the same exact value.
45
+ Three kinds of value are not digested as numbers:
46
+
47
+ * ``bool`` and other ``int`` subclasses, such as ``IntEnum`` members, digest
48
+ as their own type, so ``digest(True) != digest(1)``.
49
+ * NaNs equal nothing, so they digest by their bit pattern: NaNs with the same
50
+ bits digest alike whatever their type.
51
+ * Timedeltas (``datetime.timedelta``, ``pd.Timedelta`` and ``np.timedelta64``)
52
+ are refused with :exc:`~fleche.digest.Indigestible` for now, although numpy
53
+ counts ``np.timedelta64`` as a ``numbers.Integral``. A hook registered with
54
+ :func:`~fleche.digest.add_hook` can digest them meanwhile.
55
+
56
+ For example:
57
+
58
+ .. code-block:: python
59
+
60
+ digest(1) == digest(1.0) == digest(1 + 0j) == digest(Fraction(1)) # as int
61
+ digest(0.5) == digest(np.float32(0.5)) == digest(Decimal("0.5")) # as float
62
+ digest(Fraction(1, 10)) == digest(Decimal("0.1")) # as Fraction
63
+ digest(-1.0) != digest(-2.0)
64
+ digest(0.1) != digest(Decimal("0.1")) # the float is not exactly 1/10
65
+ digest(True) != digest(1)
66
+
67
+ A number is converted to a lower type only when the conversion is lossless, so
68
+ ``digest(a) == digest(b)`` exactly when ``a`` and ``b`` are numerically equal.
69
+ For standard library types this agrees with ``==``, but third party types do not
70
+ always: numpy casts a Python float down to the precision of a numpy scalar
71
+ before comparing (so ``np.float32(0.1) == 0.1``), rounds a ``Fraction`` to
72
+ double, and compares a huge ``np.longdouble`` with an ``int`` through their
73
+ string forms. Following that ``==`` would let unequal values share a cache
74
+ entry, so ``fleche`` goes by the exact value instead: ``np.float32(0.1)``
75
+ stores 0.100000001490116…, and digests apart from ``0.1``.
76
+
77
+ Other types
78
+ -----------
79
+
80
+ Every other value digests by its type as well as its content, so
81
+ ``digest((1, 2)) != digest([1, 2])``: a tuple and a list never digest alike,
82
+ whatever their elements.
30
83
 
31
84
  Dataclasses and ``attrs`` classes
32
85
  ---------------------------------
@@ -454,11 +454,11 @@
454
454
  "\n",
455
455
  "```python\n",
456
456
  "with cache(shared_cache):\n",
457
- " with SingleNodeExecutor() as executor:\n",
457
+ " with SingleNodeExecutor(max_workers=4) as executor: # 4 tasks at once\n",
458
458
  " wrap_executor(executor)\n",
459
459
  " future = executor.submit(\n",
460
460
  " expensive, 5,\n",
461
- " resource_dict={\"cores\": 4}, # goes to SingleNodeExecutor.submit\n",
461
+ " resource_dict={\"threads_per_core\": 2}, # goes to SingleNodeExecutor.submit\n",
462
462
  " )\n",
463
463
  " future.result() # goes to expensive\n",
464
464
  "```\n",