model2data 1.11.0__tar.gz → 1.12.0__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 (169) hide show
  1. {model2data-1.11.0/model2data.egg-info → model2data-1.12.0}/PKG-INFO +1 -1
  2. {model2data-1.11.0 → model2data-1.12.0}/README.md +12 -9
  3. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/core.py +1 -1
  4. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/timeline.py +73 -13
  5. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/when.py +17 -13
  6. {model2data-1.11.0 → model2data-1.12.0/model2data.egg-info}/PKG-INFO +1 -1
  7. {model2data-1.11.0 → model2data-1.12.0}/pyproject.toml +1 -1
  8. {model2data-1.11.0 → model2data-1.12.0}/tests/test_determinism.py +10 -9
  9. {model2data-1.11.0 → model2data-1.12.0}/tests/test_timeline.py +65 -1
  10. {model2data-1.11.0 → model2data-1.12.0}/LICENSE +0 -0
  11. {model2data-1.11.0 → model2data-1.12.0}/README_PYPI.md +0 -0
  12. {model2data-1.11.0 → model2data-1.12.0}/model2data/__init__.py +0 -0
  13. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/__init__.py +0 -0
  14. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/LICENSE +0 -0
  15. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/VENDORED.md +0 -0
  16. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/__init__.py +0 -0
  17. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
  18. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/base.py +0 -0
  19. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/column.py +0 -0
  20. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
  21. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
  22. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/index.py +0 -0
  23. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/note.py +0 -0
  24. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/project.py +0 -0
  25. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
  26. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
  27. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/table.py +0 -0
  28. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
  29. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
  30. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/constants.py +0 -0
  31. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/database.py +0 -0
  32. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
  33. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/column.py +0 -0
  34. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/common.py +0 -0
  35. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
  36. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
  37. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/index.py +0 -0
  38. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/project.py +0 -0
  39. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/records.py +0 -0
  40. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
  41. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
  42. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/table.py +0 -0
  43. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
  44. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
  45. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/exceptions.py +0 -0
  46. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
  47. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
  48. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/parser/parser.py +0 -0
  49. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
  50. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/base.py +0 -0
  51. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
  52. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
  53. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
  54. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
  55. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
  56. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
  57. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
  58. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
  59. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
  60. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
  61. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
  62. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
  63. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
  64. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
  65. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
  66. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
  67. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
  68. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
  69. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
  70. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
  71. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
  72. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
  73. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
  74. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
  75. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
  76. {model2data-1.11.0 → model2data-1.12.0}/model2data/_vendor/pydbml/tools.py +0 -0
  77. {model2data-1.11.0 → model2data-1.12.0}/model2data/cli.py +0 -0
  78. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/__init__.py +0 -0
  79. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/hint_tests.py +0 -0
  80. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/naming.py +0 -0
  81. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/project.py +0 -0
  82. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  83. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
  84. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/templates/hint_macros/model2data_when_tests.sql +0 -0
  85. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/templates/history_macros/model2data_history_tests.sql +0 -0
  86. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  87. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  88. {model2data-1.11.0 → model2data-1.12.0}/model2data/dbt/tests.py +0 -0
  89. {model2data-1.11.0 → model2data-1.12.0}/model2data/defects/__init__.py +0 -0
  90. {model2data-1.11.0 → model2data-1.12.0}/model2data/defects/apply.py +0 -0
  91. {model2data-1.11.0 → model2data-1.12.0}/model2data/defects/checks.py +0 -0
  92. {model2data-1.11.0 → model2data-1.12.0}/model2data/defects/presets.py +0 -0
  93. {model2data-1.11.0 → model2data-1.12.0}/model2data/defects/report.py +0 -0
  94. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/__init__.py +0 -0
  95. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/days.py +0 -0
  96. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/faker.py +0 -0
  97. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/hints.py +0 -0
  98. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/history.py +0 -0
  99. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/kinds.py +0 -0
  100. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/options.py +0 -0
  101. {model2data-1.11.0 → model2data-1.12.0}/model2data/generate/relationships.py +0 -0
  102. {model2data-1.11.0 → model2data-1.12.0}/model2data/guide/setup.md +0 -0
  103. {model2data-1.11.0 → model2data-1.12.0}/model2data/guide/triage.md +0 -0
  104. {model2data-1.11.0 → model2data-1.12.0}/model2data/guide/tune.md +0 -0
  105. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/__init__.py +0 -0
  106. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/_yaml.py +0 -0
  107. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/dbml.py +0 -0
  108. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/document.py +0 -0
  109. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/dump.py +0 -0
  110. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/engine.py +0 -0
  111. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/errors.py +0 -0
  112. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/reader.py +0 -0
  113. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/types.py +0 -0
  114. {model2data-1.11.0 → model2data-1.12.0}/model2data/model/validate.py +0 -0
  115. {model2data-1.11.0 → model2data-1.12.0}/model2data/output.py +0 -0
  116. {model2data-1.11.0 → model2data-1.12.0}/model2data/parse/__init__.py +0 -0
  117. {model2data-1.11.0 → model2data-1.12.0}/model2data/parse/dbml.py +0 -0
  118. {model2data-1.11.0 → model2data-1.12.0}/model2data/spec/PUBLISHING.md +0 -0
  119. {model2data-1.11.0 → model2data-1.12.0}/model2data/spec/README.md +0 -0
  120. {model2data-1.11.0 → model2data-1.12.0}/model2data/spec/examples/coffee_webshop.model2data.yml +0 -0
  121. {model2data-1.11.0 → model2data-1.12.0}/model2data/spec/model.schema.json +0 -0
  122. {model2data-1.11.0 → model2data-1.12.0}/model2data/utils.py +0 -0
  123. {model2data-1.11.0 → model2data-1.12.0}/model2data.egg-info/SOURCES.txt +0 -0
  124. {model2data-1.11.0 → model2data-1.12.0}/model2data.egg-info/dependency_links.txt +0 -0
  125. {model2data-1.11.0 → model2data-1.12.0}/model2data.egg-info/entry_points.txt +0 -0
  126. {model2data-1.11.0 → model2data-1.12.0}/model2data.egg-info/requires.txt +0 -0
  127. {model2data-1.11.0 → model2data-1.12.0}/model2data.egg-info/top_level.txt +0 -0
  128. {model2data-1.11.0 → model2data-1.12.0}/setup.cfg +0 -0
  129. {model2data-1.11.0 → model2data-1.12.0}/tests/test_as_of_anchor.py +0 -0
  130. {model2data-1.11.0 → model2data-1.12.0}/tests/test_cli.py +0 -0
  131. {model2data-1.11.0 → model2data-1.12.0}/tests/test_cli_model.py +0 -0
  132. {model2data-1.11.0 → model2data-1.12.0}/tests/test_column_time_hints.py +0 -0
  133. {model2data-1.11.0 → model2data-1.12.0}/tests/test_coverage_gaps.py +0 -0
  134. {model2data-1.11.0 → model2data-1.12.0}/tests/test_days.py +0 -0
  135. {model2data-1.11.0 → model2data-1.12.0}/tests/test_dbml_parser.py +0 -0
  136. {model2data-1.11.0 → model2data-1.12.0}/tests/test_dbml_parser_fuzz.py +0 -0
  137. {model2data-1.11.0 → model2data-1.12.0}/tests/test_dbt_integration.py +0 -0
  138. {model2data-1.11.0 → model2data-1.12.0}/tests/test_dbt_naming.py +0 -0
  139. {model2data-1.11.0 → model2data-1.12.0}/tests/test_dbt_project.py +0 -0
  140. {model2data-1.11.0 → model2data-1.12.0}/tests/test_dbt_tests.py +0 -0
  141. {model2data-1.11.0 → model2data-1.12.0}/tests/test_defects.py +0 -0
  142. {model2data-1.11.0 → model2data-1.12.0}/tests/test_defects_dbt.py +0 -0
  143. {model2data-1.11.0 → model2data-1.12.0}/tests/test_defects_replay.py +0 -0
  144. {model2data-1.11.0 → model2data-1.12.0}/tests/test_defects_report.py +0 -0
  145. {model2data-1.11.0 → model2data-1.12.0}/tests/test_defects_review.py +0 -0
  146. {model2data-1.11.0 → model2data-1.12.0}/tests/test_distributions.py +0 -0
  147. {model2data-1.11.0 → model2data-1.12.0}/tests/test_examples_model.py +0 -0
  148. {model2data-1.11.0 → model2data-1.12.0}/tests/test_faker_name_inference.py +0 -0
  149. {model2data-1.11.0 → model2data-1.12.0}/tests/test_fast_paths.py +0 -0
  150. {model2data-1.11.0 → model2data-1.12.0}/tests/test_generation.py +0 -0
  151. {model2data-1.11.0 → model2data-1.12.0}/tests/test_guide.py +0 -0
  152. {model2data-1.11.0 → model2data-1.12.0}/tests/test_hint_tests.py +0 -0
  153. {model2data-1.11.0 → model2data-1.12.0}/tests/test_history.py +0 -0
  154. {model2data-1.11.0 → model2data-1.12.0}/tests/test_issue_lines.py +0 -0
  155. {model2data-1.11.0 → model2data-1.12.0}/tests/test_lone_country.py +0 -0
  156. {model2data-1.11.0 → model2data-1.12.0}/tests/test_model.py +0 -0
  157. {model2data-1.11.0 → model2data-1.12.0}/tests/test_model_dbml.py +0 -0
  158. {model2data-1.11.0 → model2data-1.12.0}/tests/test_model_edges.py +0 -0
  159. {model2data-1.11.0 → model2data-1.12.0}/tests/test_model_messages.py +0 -0
  160. {model2data-1.11.0 → model2data-1.12.0}/tests/test_model_vocabulary.py +0 -0
  161. {model2data-1.11.0 → model2data-1.12.0}/tests/test_options.py +0 -0
  162. {model2data-1.11.0 → model2data-1.12.0}/tests/test_release_stress.py +0 -0
  163. {model2data-1.11.0 → model2data-1.12.0}/tests/test_reproducibility.py +0 -0
  164. {model2data-1.11.0 → model2data-1.12.0}/tests/test_row_identity.py +0 -0
  165. {model2data-1.11.0 → model2data-1.12.0}/tests/test_shaping.py +0 -0
  166. {model2data-1.11.0 → model2data-1.12.0}/tests/test_spec_alignment.py +0 -0
  167. {model2data-1.11.0 → model2data-1.12.0}/tests/test_spec_schema.py +0 -0
  168. {model2data-1.11.0 → model2data-1.12.0}/tests/test_table_seeds.py +0 -0
  169. {model2data-1.11.0 → model2data-1.12.0}/tests/test_when.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 1.11.0
3
+ Version: 1.12.0
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
5
  Author: JB Analytica
6
6
  License-Expression: MIT
@@ -57,8 +57,9 @@ access required.
57
57
  - **Relationship-preserving.** Foreign keys resolve to real parent rows; tables are generated in
58
58
  dependency order.
59
59
  - **Deterministic, byte for byte.** The same model, `--seed` and `--as-of` (and the same options)
60
- give the same files, byte for byte, on any machine and in every 1.x release — safe to commit
61
- fixtures, safe to diff across CI runs and upgrades. See [The determinism promise](#the-determinism-promise).
60
+ give the same files, byte for byte, on any machine and any day with the same model2data
61
+ version — safe to commit fixtures, safe to diff across CI runs. See
62
+ [The determinism promise](#the-determinism-promise).
62
63
  - **Re-rollable one table at a time.** `--table-seed orders=7` regenerates a single table and
63
64
  leaves every other table byte-identical, so you can keep the four tables that look right.
64
65
  - **A real dbt project, not just CSVs.** Seeds, staging models that `ref()` them, schema tests,
@@ -229,14 +230,14 @@ jobs:
229
230
  ```
230
231
 
231
232
  Inputs: `files` (glob, default `**/*.model2data.yml`), `version` (default: the release the tag
232
- points at, e.g. `1.11.0`; a specifier such as `>=1.10,<2` also works), `python-version` (3.12).
233
+ points at, e.g. `1.12.0`; a specifier such as `>=1.10,<2` also works), `python-version` (3.12).
233
234
 
234
235
  **pre-commit**
235
236
 
236
237
  ```yaml
237
238
  repos:
238
239
  - repo: https://github.com/JB-Analytica/model2data
239
- rev: v1.11.0
240
+ rev: v1.12.0
240
241
  hooks:
241
242
  - id: model2data-validate
242
243
  ```
@@ -250,7 +251,7 @@ model2data:
250
251
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
251
252
  changes: ["**/*.model2data.yml"]
252
253
  script:
253
- - pip install model2data==1.11.0
254
+ - pip install model2data==1.12.0
254
255
  - model2data validate --glob "**/*.model2data.yml"
255
256
  ```
256
257
 
@@ -323,13 +324,15 @@ The same model, seed and `--as-of` — with the same options (`--rows`, `--rows-
323
324
 
324
325
  - on any machine and operating system, and on every supported Python version;
325
326
  - with any Faker or pandas version model2data accepts;
326
- - in every 1.x release. A release may make generation faster or add options, but never changes
327
- what an existing model, seed and set of options produce. A fix that cannot keep that waits for
328
- a new major version, and the changelog says so.
327
+ - with the same model2data version, whenever you run it.
328
+
329
+ A new release may change what a seed produces, to fix a bug or make the data more realistic.
330
+ When it does, the changelog says which models are affected and why. Pin the version
331
+ (`model2data==1.11.0`) when fixtures must stay the same across upgrades.
329
332
 
330
333
  `tests/test_determinism.py` holds the file hashes of a spread of models, presets, multi-day runs
331
334
  and locales, and runs on every pull request — against the newest Faker and pandas too — so a
332
- change to a single output byte fails the build rather than reaching a release.
335
+ change to a single output byte fails the build unless it is deliberate and in the changelog.
333
336
 
334
337
  If one table comes out wrong and the rest looks right, `--table-seed` re-rolls just that table.
335
338
  Every other table's seed CSV stays byte-identical, and children of the re-rolled table still
@@ -248,7 +248,7 @@ def generate_data_from_dbml(
248
248
  # composite-key retry regenerates a temporal column's value into a
249
249
  # frame that already respects created/updated/closed ordering, rather
250
250
  # than one where only the untouched columns do.
251
- df = order_row_times(df, table_def, as_of=as_of)
251
+ df = order_row_times(df, table_def, as_of=as_of, time_profile=profile)
252
252
  df = _resolve_self_referencing_fks(
253
253
  df,
254
254
  table_def,
@@ -197,6 +197,60 @@ def weighted_timestamps(row_count: int, profile: TimeProfile, anchor: date) -> l
197
197
  _TIMESTAMP_GAP_SCALE_SECONDS = 3 * 86400
198
198
  _DATE_GAP_SCALE_DAYS = 3
199
199
 
200
+ # How many gaps `place_after` draws for a moment inside the window, in the
201
+ # column's shape, before it settles for one drawn evenly between the bounds.
202
+ _PLACE_TRIES = 20
203
+
204
+ # A weekend day's weight against a weekday's under `business_hours`, as
205
+ # `_day_weight` weighs it.
206
+ _WEEKEND_WEIGHT = 0.3
207
+
208
+
209
+ def gap(kind: str) -> timedelta:
210
+ """How far after what it follows a dependent column lands: 3 days on average."""
211
+ if kind == "timestamp":
212
+ return timedelta(seconds=round(random.expovariate(1 / _TIMESTAMP_GAP_SCALE_SECONDS)))
213
+ return timedelta(days=max(0, round(random.expovariate(1 / _DATE_GAP_SCALE_DAYS))))
214
+
215
+
216
+ def place_after(lower: datetime, kind: str, upper: datetime, business_hours: bool) -> datetime:
217
+ """A moment a random gap after `lower`, in the column's shape, before `upper`.
218
+
219
+ The gap decides roughly when; `business_hours` then decides the weekday
220
+ and the hour, as it does for a column drawn on its own: a weekend day is
221
+ kept 3 times in 10, and a timestamp's time of day is redrawn from
222
+ `HOUR_WEIGHTS` on the gap's day (a time before `lower` is drawn again).
223
+ A timestamp lands before `upper`, a date on or before it. A draw past
224
+ `upper` is drawn again, so a short window does not pile rows up on its
225
+ last moment; after `_PLACE_TRIES` the moment is drawn evenly between the
226
+ bounds, and a window with no room at all returns `lower`: the column
227
+ never lands before what it follows.
228
+ """
229
+
230
+ def inside(moment: datetime) -> bool:
231
+ return moment < upper if kind == "timestamp" else moment.date() <= upper.date()
232
+
233
+ for _ in range(_PLACE_TRIES):
234
+ moment = lower + gap(kind)
235
+ if business_hours:
236
+ if moment.weekday() >= 5 and random.random() >= _WEEKEND_WEIGHT:
237
+ continue
238
+ if kind == "timestamp":
239
+ hour = random.choices(range(24), weights=HOUR_WEIGHTS)[0]
240
+ moment = datetime(moment.year, moment.month, moment.day, hour) + timedelta(
241
+ seconds=random.randrange(3600)
242
+ )
243
+ if moment < lower:
244
+ continue
245
+ if inside(moment):
246
+ return moment
247
+ if kind == "timestamp":
248
+ span = int((upper - lower).total_seconds())
249
+ return lower + timedelta(seconds=random.randrange(span)) if span > 0 else lower
250
+ span = (upper.date() - lower.date()).days
251
+ return lower + timedelta(days=random.randint(0, span)) if span > 0 else lower
252
+
253
+
200
254
  # Column-name stems that place a column at a stage in the created -> updated ->
201
255
  # closed chain. A column matches a stage by tokenizing its name on non-
202
256
  # alphanumeric characters and looking for the stem as a contiguous run of
@@ -330,6 +384,14 @@ def _format_value(moment: datetime, kind: str) -> Union[str, date]:
330
384
  return moment.date()
331
385
 
332
386
 
387
+ def column_business_hours(column: ColumnDef, time_profile: Optional[TimeProfile]) -> bool:
388
+ """Whether `column` is drawn in business hours: its own hint, else the run's."""
389
+ note = column.note or {}
390
+ if "business_hours" in note:
391
+ return bool(note["business_hours"])
392
+ return bool(time_profile and time_profile.business_hours)
393
+
394
+
333
395
  def _build_dependencies(
334
396
  table_def: TableDef, columns_by_name: dict, temporal_columns: list[ColumnDef]
335
397
  ) -> dict[str, set[str]]:
@@ -413,15 +475,20 @@ def _topological_order(table_def: TableDef, deps: dict[str, set[str]]) -> list[s
413
475
  return order
414
476
 
415
477
 
416
- def order_row_times(df, table_def: TableDef, as_of: AsOf = None):
478
+ def order_row_times(
479
+ df, table_def: TableDef, as_of: AsOf = None, time_profile: Optional[TimeProfile] = None
480
+ ):
417
481
  """Fix up temporal columns so an earlier-stage column never lands later.
418
482
 
419
483
  Runs unconditionally (every profile, not just non-uniform ones): a row
420
484
  updated before it was created is wrong regardless of how the timestamps
421
485
  were drawn. A column with dependencies is set, per row, to the latest of
422
- those dependencies plus a random gap (capped at the window's end), unless
423
- either side is null for that row -- a nullable column that happened to
424
- come back null keeps its null, rather than this pass forcing a value in.
486
+ those dependencies plus a random gap, inside the window and in the
487
+ column's business-hours shape (see `place_after`; `time_profile` is the
488
+ run's, a column's own `business_hours` hint replacing it), unless either
489
+ side is null for that row -- a nullable column that happened to come back
490
+ null keeps its null, rather than this pass forcing a value in. The run's
491
+ growth and seasonality reach the column through the one it follows.
425
492
 
426
493
  Leaves `df` untouched when the table holds no `after` hint and no
427
494
  recognisable created/updated/closed-style name pair, and never modifies a
@@ -456,7 +523,7 @@ def order_row_times(df, table_def: TableDef, as_of: AsOf = None):
456
523
  other_kind = _column_kind(columns_by_name[name].data_type)
457
524
  assert other_kind is not None
458
525
  required_kinds[name] = other_kind
459
- scale = _TIMESTAMP_GAP_SCALE_SECONDS if kind == "timestamp" else _DATE_GAP_SCALE_DAYS
526
+ business_hours = column_business_hours(columns_by_name[column_name], time_profile)
460
527
 
461
528
  for idx in df.index:
462
529
  if _parse_value(df.at[idx, column_name], kind) is None:
@@ -475,14 +542,7 @@ def order_row_times(df, table_def: TableDef, as_of: AsOf = None):
475
542
  continue
476
543
  latest_required = max(earlier)
477
544
 
478
- gap = random.expovariate(1 / scale)
479
- if kind == "timestamp":
480
- moment = latest_required + timedelta(seconds=round(gap))
481
- else:
482
- moment = latest_required + timedelta(days=max(0, round(gap)))
483
- if moment > cap:
484
- moment = cap
485
-
545
+ moment = place_after(latest_required, kind, cap, business_hours)
486
546
  df.at[idx, column_name] = _format_value(moment, kind)
487
547
 
488
548
  return df
@@ -45,12 +45,12 @@ from model2data.generate import kinds
45
45
  from model2data.generate.faker import AsOf, generate_column_values
46
46
  from model2data.generate.options import TimeProfile
47
47
  from model2data.generate.timeline import (
48
- _DATE_GAP_SCALE_DAYS,
49
- _TIMESTAMP_GAP_SCALE_SECONDS,
50
48
  _build_dependencies,
51
49
  _format_value,
52
50
  _parse_value,
53
51
  _topological_order,
52
+ column_business_hours,
53
+ place_after,
54
54
  )
55
55
  from model2data.parse.dbml import ColumnDef, TableDef
56
56
 
@@ -123,7 +123,13 @@ def _temporal_kind(column: ColumnDef) -> Optional[str]:
123
123
  class _Order:
124
124
  """What a filled date or timestamp of one table must sit between, row by row."""
125
125
 
126
- def __init__(self, table_def: TableDef, frame: pd.DataFrame):
126
+ def __init__(
127
+ self,
128
+ table_def: TableDef,
129
+ frame: pd.DataFrame,
130
+ time_profile: Optional[TimeProfile] = None,
131
+ ):
132
+ self.time_profile = time_profile
127
133
  self.by_name = {column.name: column for column in table_def.columns}
128
134
  temporal = [
129
135
  column
@@ -154,6 +160,11 @@ class _Order:
154
160
  found = self._moments(followers, row)
155
161
  return min(found) if found else None
156
162
 
163
+ def place(self, name: str, lower: datetime, kind: str, upper: datetime) -> datetime:
164
+ """A moment for `name` a random gap after `lower`, in the column's shape."""
165
+ business_hours = column_business_hours(self.by_name[name], self.time_profile)
166
+ return place_after(lower, kind, upper, business_hours)
167
+
157
168
  def settle(self, name: str, row: int, cap: datetime) -> None:
158
169
  """Move what follows `name` in this row to no earlier than what it follows, in order.
159
170
 
@@ -172,17 +183,10 @@ class _Order:
172
183
  lower = self.after(other, row)
173
184
  if value is None or lower is None or value >= lower:
174
185
  continue
175
- self.frame.iat[row, column] = _format_value(min(lower + _gap(kind), cap), kind)
186
+ self.frame.iat[row, column] = _format_value(self.place(other, lower, kind, cap), kind)
176
187
  moved.add(other)
177
188
 
178
189
 
179
- def _gap(kind: str) -> timedelta:
180
- """How far after what it follows a filled value lands: the ordering pass's own gap."""
181
- if kind == "timestamp":
182
- return timedelta(seconds=round(random.expovariate(1 / _TIMESTAMP_GAP_SCALE_SECONDS)))
183
- return timedelta(days=max(0, round(random.expovariate(1 / _DATE_GAP_SCALE_DAYS))))
184
-
185
-
186
190
  def _draw_like(
187
191
  column: ColumnDef,
188
192
  held: list,
@@ -223,7 +227,7 @@ def apply_when(
223
227
  columns = when_columns(table_def)
224
228
  if not columns:
225
229
  return
226
- order = _Order(table_def, frame)
230
+ order = _Order(table_def, frame, time_profile)
227
231
  for column in columns:
228
232
  name = column.name
229
233
  match = matching(frame, order.by_name, conditions(column) or {})
@@ -274,7 +278,7 @@ def _moment(
274
278
  span = max(0, int((upper - start).total_seconds()))
275
279
  moment = start + timedelta(seconds=random.randint(0, span))
276
280
  elif lower is not None:
277
- moment = lower + _gap(kind)
281
+ moment = order.place(name, lower, kind, upper)
278
282
  else:
279
283
  held = [
280
284
  parsed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 1.11.0
3
+ Version: 1.12.0
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
5
  Author: JB Analytica
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "model2data"
7
- version = "1.11.0"
7
+ version = "1.12.0"
8
8
  description = "Generate analytics-ready datasets from DBML models"
9
9
  readme = "README_PYPI.md"
10
10
  requires-python = ">=3.10"
@@ -1,15 +1,16 @@
1
- """The same model, seed and `--as-of` write byte-identical files in every 1.x release.
1
+ """The same model, seed and `--as-of` write byte-identical files on the same version.
2
2
 
3
3
  That is the product's promise: a committed dataset, a diffed CI run and a teaching
4
4
  exercise all lean on it. This module enforces it. Each case runs the real CLI
5
5
  in-process, hashes every file it writes, and compares against the hashes in
6
- `fixtures/determinism.json`, which were recorded from a published release.
6
+ `fixtures/determinism.json`.
7
7
 
8
- If a case fails, output changed for the same seed. That is a breaking change and
9
- needs a major version; it is not a snapshot to refresh. Faker or pandas releases
10
- that change the data show up here too (CI runs this file against the newest of both).
8
+ If a case fails, output changed for the same seed. That is not a snapshot to
9
+ refresh: either the change is unintended (find it), or it is deliberate and the
10
+ changelog says which models it affects and why. Faker or pandas releases that
11
+ change the data show up here too (CI runs this file against the newest of both).
11
12
 
12
- Re-recording is for adding or deliberately versioned changes only:
13
+ Re-recording is for adding cases or deliberate, changelogged changes only:
13
14
 
14
15
  MODEL2DATA_RECORD_DETERMINISM=1 uv run pytest tests/test_determinism.py
15
16
 
@@ -123,9 +124,9 @@ def test_same_seed_same_bytes(case_id, tmp_path, recorded):
123
124
  f"DETERMINISM BROKEN in case {case_id!r}: the same model, seed and --as-of no "
124
125
  "longer write the same files.\n"
125
126
  f" changed files: {changed}\n new files: {added}\n missing files: {missing}\n"
126
- "Changing output for the same seed breaks model2data's promise of byte-identical "
127
- "output across 1.x releases and needs a MAJOR version. Do not just re-record the "
128
- "hashes. If the change comes from a Faker or pandas release, pin or work around it."
127
+ "Do not just re-record the hashes: re-record only for a deliberate change the "
128
+ "CHANGELOG describes (which models, and why). If the change comes from a Faker or "
129
+ "pandas release, pin or work around it."
129
130
  )
130
131
 
131
132
 
@@ -10,13 +10,15 @@ change is deliberate and is called out in CHANGELOG.md, but it still needs a
10
10
  fixed expectation so a future refactor can't silently drift it further.
11
11
  """
12
12
 
13
+ import random
13
14
  from datetime import date, datetime, timedelta
14
15
 
16
+ import pandas as pd
15
17
  import pytest
16
18
 
17
19
  from model2data.generate.core import generate_data_from_dbml
18
20
  from model2data.generate.options import TimeProfile
19
- from model2data.generate.timeline import order_row_times
21
+ from model2data.generate.timeline import order_row_times, place_after
20
22
  from model2data.parse.dbml import ColumnDef, TableDef
21
23
 
22
24
  ANCHOR = date(2026, 3, 15)
@@ -315,6 +317,68 @@ def test_after_hint_is_honoured():
315
317
  assert datetime.fromisoformat(ordered) <= datetime.fromisoformat(shipped)
316
318
 
317
319
 
320
+ def _bookings(scheduled_note: dict, rows: int = 2000, profile=None) -> pd.DataFrame:
321
+ tables = {
322
+ "bookings": TableDef(
323
+ name="bookings",
324
+ columns=[
325
+ ColumnDef("id", "bigint", {"pk"}),
326
+ ColumnDef("booked_at", "timestamp", {"not null"}),
327
+ ColumnDef("scheduled_for", "timestamp", {"not null"}, note=scheduled_note),
328
+ ],
329
+ )
330
+ }
331
+ return generate_data_from_dbml(
332
+ tables, [], base_rows=rows, seed=1, as_of=ANCHOR, time_profile=profile
333
+ )["bookings"]
334
+
335
+
336
+ def _working_share(values) -> float:
337
+ moments = [datetime.fromisoformat(v) for v in values]
338
+ return sum(1 for t in moments if t.weekday() < 5 and 8 <= t.hour < 18) / len(moments)
339
+
340
+
341
+ def test_a_column_with_after_keeps_its_business_hours():
342
+ """The gap decides roughly when; the column's `business_hours` decides the hour."""
343
+ df = _bookings({"after": "booked_at", "business_hours": True})
344
+
345
+ assert _working_share(df["scheduled_for"]) >= 0.65
346
+ assert _working_share(df["booked_at"]) < 0.4
347
+ for booked, scheduled in zip(df["booked_at"], df["scheduled_for"], strict=True):
348
+ assert datetime.fromisoformat(booked) <= datetime.fromisoformat(scheduled)
349
+
350
+
351
+ def test_the_runs_business_hours_reach_a_column_with_after():
352
+ df = _bookings({"after": "booked_at"}, profile=TimeProfile(business_hours=True))
353
+
354
+ assert _working_share(df["scheduled_for"]) >= 0.65
355
+
356
+
357
+ def test_a_column_with_after_does_not_pile_up_on_as_of_midnight():
358
+ """A gap past the window is drawn again, not set to the window's end."""
359
+ df = _bookings({"after": "booked_at"})
360
+ cap = datetime(ANCHOR.year, ANCHOR.month, ANCHOR.day)
361
+
362
+ scheduled = [datetime.fromisoformat(v) for v in df["scheduled_for"]]
363
+ assert max(scheduled) < cap
364
+ # Two rows may share a second by chance; the old clamp put dozens on one.
365
+ assert max(scheduled.count(moment) for moment in scheduled) <= 2
366
+
367
+
368
+ def test_place_after_stays_between_its_bounds():
369
+ random.seed(0)
370
+ lower = datetime(2026, 3, 14, 23, 0)
371
+ upper = datetime(2026, 3, 15)
372
+ for business_hours in (False, True):
373
+ moments = [place_after(lower, "timestamp", upper, business_hours) for _ in range(200)]
374
+ assert all(lower <= moment < upper for moment in moments)
375
+ assert len(set(moments)) > 150
376
+
377
+ dates = [place_after(lower, "date", upper, True) for _ in range(50)]
378
+ assert {moment.date() for moment in dates} <= {date(2026, 3, 14), date(2026, 3, 15)}
379
+ assert place_after(upper, "timestamp", upper, False) == upper
380
+
381
+
318
382
  def test_start_end_suffix_pair_is_honoured():
319
383
  tables = {
320
384
  "subscriptions": TableDef(
File without changes
File without changes
File without changes