tutor 23.0.0.dev3203__tar.gz → 23.0.0.dev3207__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 (111) hide show
  1. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/PKG-INFO +5 -2
  2. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/requirements/base.in +1 -0
  3. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/requirements/dev.txt +7 -1
  4. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/compose.py +3 -2
  5. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/config.py +15 -2
  6. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/k8s.py +3 -2
  7. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/plugins.py +12 -4
  8. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/config.py +95 -2
  9. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/interactive.py +11 -2
  10. tutor-23.0.0.dev3207/tutor/serialize.py +193 -0
  11. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/Dockerfile +14 -4
  12. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/config/defaults.yml +1 -1
  13. tutor-23.0.0.dev3203/tutor/serialize.py +0 -80
  14. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/.gitignore +0 -0
  15. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/.hatch_build.py +0 -0
  16. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/LICENSE.txt +0 -0
  17. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/README.rst +0 -0
  18. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/pyproject.toml +0 -0
  19. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/requirements/plugins.txt +0 -0
  20. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/__about__.py +0 -0
  21. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/__init__.py +0 -0
  22. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/bindmount.py +0 -0
  23. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/__init__.py +0 -0
  24. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/cli.py +0 -0
  25. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/context.py +0 -0
  26. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/dev.py +0 -0
  27. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/images.py +0 -0
  28. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/jobs.py +0 -0
  29. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/jobs_utils.py +0 -0
  30. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/local.py +0 -0
  31. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/mounts.py +0 -0
  32. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/params.py +0 -0
  33. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/upgrade/__init__.py +0 -0
  34. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/upgrade/common.py +0 -0
  35. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/upgrade/compose.py +0 -0
  36. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/commands/upgrade/k8s.py +0 -0
  37. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/core/__init__.py +0 -0
  38. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/core/hooks/__init__.py +0 -0
  39. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/core/hooks/actions.py +0 -0
  40. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/core/hooks/contexts.py +0 -0
  41. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/core/hooks/filters.py +0 -0
  42. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/core/hooks/priorities.py +0 -0
  43. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/env.py +0 -0
  44. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/exceptions.py +0 -0
  45. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/fmt.py +0 -0
  46. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/hooks/__init__.py +0 -0
  47. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/hooks/catalog.py +0 -0
  48. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/images.py +0 -0
  49. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/plugins/__init__.py +0 -0
  50. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/plugins/base.py +0 -0
  51. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/plugins/indexes.py +0 -0
  52. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/plugins/openedx.py +0 -0
  53. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/plugins/v0.py +0 -0
  54. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/plugins/v1.py +0 -0
  55. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/py.typed +0 -0
  56. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/tasks.py +0 -0
  57. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/caddy/Caddyfile +0 -0
  58. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/config/cms.env.yml +0 -0
  59. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/config/lms.env.yml +0 -0
  60. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/config/partials/auth.yml +0 -0
  61. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/granian-config +0 -0
  62. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/cms/__init__.py +0 -0
  63. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/cms/development.py +0 -0
  64. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/cms/production.py +0 -0
  65. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/cms/test.py +0 -0
  66. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/lms/__init__.py +0 -0
  67. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/lms/development.py +0 -0
  68. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/lms/production.py +0 -0
  69. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/lms/test.py +0 -0
  70. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/partials/common_all.py +0 -0
  71. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/partials/common_cms.py +0 -0
  72. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/partials/common_lms.py +0 -0
  73. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/openedx/settings/partials/common_test.py +0 -0
  74. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/permissions/setowners.sh +0 -0
  75. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/apps/redis/redis.conf +0 -0
  76. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/bin/reload-granian +0 -0
  77. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/bin/run-granian +0 -0
  78. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/revisions.yml +0 -0
  79. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/settings/cms/__init__.py +0 -0
  80. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/settings/cms/assets.py +0 -0
  81. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/settings/cms/i18n.py +0 -0
  82. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/settings/granian-config +0 -0
  83. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/settings/lms/__init__.py +0 -0
  84. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/settings/lms/assets.py +0 -0
  85. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/settings/lms/i18n.py +0 -0
  86. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/settings/partials/assets.py +0 -0
  87. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/settings/partials/i18n.py +0 -0
  88. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/openedx/themes/README +0 -0
  89. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/permissions/Dockerfile +0 -0
  90. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/build/permissions/setowner.sh +0 -0
  91. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/config/base.yml +0 -0
  92. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/dev/docker-compose.jobs.yml +0 -0
  93. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/dev/docker-compose.yml +0 -0
  94. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/jobs/init/cms.sh +0 -0
  95. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/jobs/init/lms.sh +0 -0
  96. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/jobs/init/meilisearch.sh +0 -0
  97. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/jobs/init/mounted-directories.sh +0 -0
  98. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/jobs/init/mysql.sh +0 -0
  99. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/k8s/deployments.yml +0 -0
  100. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/k8s/jobs.yml +0 -0
  101. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/k8s/namespace.yml +0 -0
  102. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/k8s/override.yml +0 -0
  103. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/k8s/services.yml +0 -0
  104. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/k8s/volumes.yml +0 -0
  105. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/kustomization.yml +0 -0
  106. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/local/docker-compose.jobs.yml +0 -0
  107. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/local/docker-compose.prod.yml +0 -0
  108. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/local/docker-compose.yml +0 -0
  109. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/templates/version +0 -0
  110. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/types.py +0 -0
  111. {tutor-23.0.0.dev3203 → tutor-23.0.0.dev3207}/tutor/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tutor
3
- Version: 23.0.0.dev3203
3
+ Version: 23.0.0.dev3207
4
4
  Summary: The Docker-based Open edX distribution designed for peace of mind
5
5
  Project-URL: Homepage, https://docs.tutor.edly.io/
6
6
  Project-URL: Documentation, https://docs.tutor.edly.io/
@@ -33,6 +33,7 @@ Requires-Dist: mypy
33
33
  Requires-Dist: packaging
34
34
  Requires-Dist: pycryptodome>=3.17.0
35
35
  Requires-Dist: pyyaml>=6.0
36
+ Requires-Dist: ruamel-yaml>=0.18.11
36
37
  Requires-Dist: typing-extensions>=4.4.0
37
38
  Provides-Extra: dev
38
39
  Requires-Dist: altgraph==0.17.5; extra == 'dev'
@@ -41,7 +42,7 @@ Requires-Dist: attrs==26.1.0; extra == 'dev'
41
42
  Requires-Dist: backports-tarfile==1.2.0; extra == 'dev'
42
43
  Requires-Dist: build==1.4.4; extra == 'dev'
43
44
  Requires-Dist: cachetools==5.5.2; extra == 'dev'
44
- Requires-Dist: certifi==2025.8.3; extra == 'dev'
45
+ Requires-Dist: certifi==2026.10.19; extra == 'dev'
45
46
  Requires-Dist: charset-normalizer==3.4.7; extra == 'dev'
46
47
  Requires-Dist: click-log==0.4.0; extra == 'dev'
47
48
  Requires-Dist: click==8.5.0; extra == 'dev'
@@ -92,6 +93,8 @@ Requires-Dist: requests==2.32.5; extra == 'dev'
92
93
  Requires-Dist: rfc3986==2.0.0; extra == 'dev'
93
94
  Requires-Dist: rich==15.0.0; extra == 'dev'
94
95
  Requires-Dist: rsa==4.9.1; extra == 'dev'
96
+ Requires-Dist: ruamel-yaml-clib==0.2.15; extra == 'dev'
97
+ Requires-Dist: ruamel-yaml==0.19.1; extra == 'dev'
95
98
  Requires-Dist: ruff==0.15.14; extra == 'dev'
96
99
  Requires-Dist: scriv==1.7.0; extra == 'dev'
97
100
  Requires-Dist: six==1.17.0; extra == 'dev'
@@ -6,6 +6,7 @@ mypy
6
6
  packaging
7
7
  pycryptodome>=3.17.0
8
8
  pyyaml>=6.0
9
+ ruamel.yaml>=0.18.11
9
10
  typing-extensions>=4.4.0
10
11
  importlib-metadata>=7.0.1
11
12
  importlib-resources>=6.1.1
@@ -20,7 +20,7 @@ cachetools==5.5.2
20
20
  # via
21
21
  # -r requirements/base.txt
22
22
  # google-auth
23
- certifi==2025.8.3
23
+ certifi==2026.10.19
24
24
  # via
25
25
  # -r requirements/base.txt
26
26
  # kubernetes
@@ -190,6 +190,12 @@ rsa==4.9.1
190
190
  # via
191
191
  # -r requirements/base.txt
192
192
  # google-auth
193
+ ruamel-yaml==0.19.1
194
+ # via -r requirements/base.txt
195
+ ruamel-yaml-clib==0.2.15
196
+ # via
197
+ # -r requirements/base.txt
198
+ # ruamel-yaml
193
199
  ruff==0.15.14
194
200
  # via -r requirements/dev.in
195
201
  scriv==1.7.0
@@ -222,13 +222,14 @@ def interactive_configuration(
222
222
  run_for_prod: t.Optional[bool] = None,
223
223
  ) -> None:
224
224
  config = tutor_config.load_minimal(context.obj.root)
225
+ answered: list[str] = []
225
226
  if interactive:
226
227
  click.echo(fmt.title("Interactive platform configuration"))
227
- interactive_config.ask_questions(
228
+ answered = interactive_config.ask_questions(
228
229
  config,
229
230
  run_for_prod=run_for_prod,
230
231
  )
231
- tutor_config.save_config_file(context.obj.root, config)
232
+ tutor_config.save_config_file(context.obj.root, config, explicit=answered)
232
233
  config = tutor_config.load_full(context.obj.root)
233
234
  tutor_env.save(context.obj.root, config)
234
235
 
@@ -170,8 +170,9 @@ def save(
170
170
  if run_clean:
171
171
  env.delete_env_dir(context.root)
172
172
 
173
+ answered: list[str] = []
173
174
  if interactive:
174
- interactive_config.ask_questions(config)
175
+ answered = interactive_config.ask_questions(config)
175
176
  elif clean_env:
176
177
  env.delete_env_dir(context.root)
177
178
  if set_vars:
@@ -207,7 +208,19 @@ def save(
207
208
  for key in unset_vars:
208
209
  config.pop(key, None)
209
210
  if not env_only:
210
- tutor_config.save_config_file(context.root, config)
211
+ # Entries that the user named on the command line are saved as literal
212
+ # values, even when config.yml holds a Jinja expression that renders to
213
+ # the same result. So are the interactive answers that differ from the
214
+ # current value, but not the accepted defaults: accepting a default is
215
+ # not a request to replace an expression by its result.
216
+ explicit = (
217
+ answered
218
+ + [key for key, _value in set_vars]
219
+ + [key for key, _value in append_vars]
220
+ + [key for key, _value in remove_vars]
221
+ + list(unset_vars)
222
+ )
223
+ tutor_config.save_config_file(context.root, config, explicit=explicit)
211
224
 
212
225
  # Reload configuration, without version checking
213
226
  config = tutor_config.load_full(context.root)
@@ -241,10 +241,11 @@ def launch(context: click.Context, non_interactive: bool) -> None:
241
241
  )
242
242
 
243
243
  config = tutor_config.load_minimal(context.obj.root)
244
+ answered: list[str] = []
244
245
  if not non_interactive:
245
246
  click.echo(fmt.title("Interactive platform configuration"))
246
- interactive_config.ask_questions(config, run_for_prod=True)
247
- tutor_config.save_config_file(context.obj.root, config)
247
+ answered = interactive_config.ask_questions(config, run_for_prod=True)
248
+ tutor_config.save_config_file(context.obj.root, config, explicit=answered)
248
249
  config = tutor_config.load_full(context.obj.root)
249
250
  tutor_env.save(context.obj.root, config)
250
251
 
@@ -143,7 +143,9 @@ def enable(context: click.Context, plugin_names: list[str]) -> None:
143
143
  plugins.load(plugin)
144
144
  fmt.echo_info(f"Plugin {plugin} enabled")
145
145
  tutor_config.save_enabled_plugins(config)
146
- tutor_config.save_config_file(context.obj.root, config)
146
+ tutor_config.save_config_file(
147
+ context.obj.root, config, explicit=[tutor_config.PLUGINS_CONFIG_KEY]
148
+ )
147
149
  context.invoke(config_save_command, env_only=True)
148
150
 
149
151
 
@@ -166,7 +168,9 @@ def disable(context: click.Context, plugin_names: list[str]) -> None:
166
168
  disabled.append(plugin)
167
169
  fmt.echo_info(f"Plugin {plugin} disabled")
168
170
  if disabled:
169
- tutor_config.save_config_file(context.obj.root, config)
171
+ tutor_config.save_config_file(
172
+ context.obj.root, config, explicit=[tutor_config.PLUGINS_CONFIG_KEY]
173
+ )
170
174
  context.invoke(config_save_command, env_only=True)
171
175
 
172
176
 
@@ -458,7 +462,9 @@ def index_add(context: Context, url: str) -> None:
458
462
  """
459
463
  config = tutor_config.load_minimal(context.root)
460
464
  if indexes.add(url, config):
461
- tutor_config.save_config_file(context.root, config)
465
+ tutor_config.save_config_file(
466
+ context.root, config, explicit=[indexes.PLUGIN_INDEXES_KEY]
467
+ )
462
468
  update_indexes(config)
463
469
  else:
464
470
  fmt.echo_alert("Plugin index was already added")
@@ -473,7 +479,9 @@ def index_remove(context: Context, url: str) -> None:
473
479
  """
474
480
  config = tutor_config.load_minimal(context.root)
475
481
  if indexes.remove(url, config):
476
- tutor_config.save_config_file(context.root, config)
482
+ tutor_config.save_config_file(
483
+ context.root, config, explicit=[indexes.PLUGIN_INDEXES_KEY]
484
+ )
477
485
  update_indexes(config)
478
486
  else:
479
487
  fmt.echo_alert("Plugin index not present")
@@ -278,14 +278,107 @@ def convert_json2yml(root: str) -> None:
278
278
  )
279
279
 
280
280
 
281
- def save_config_file(root: str, config: Config) -> None:
281
+ def save_config_file(
282
+ root: str, config: Config, explicit: t.Optional[t.Iterable[str]] = None
283
+ ) -> None:
284
+ """
285
+ Write the configuration to config.yml.
286
+
287
+ The file is not re-generated from scratch: instead, the document that is
288
+ already on disk is modified in place, such that comments, key order and
289
+ hand-written Jinja expressions are all preserved. Only the entries that
290
+ actually changed are written, and the file is left untouched when there is
291
+ nothing to change.
292
+
293
+ `explicit` lists the entries that the user set explicitly, for instance with
294
+ `tutor config save --set KEY=VALUE`. Those are always written as literal
295
+ values, even when the file happens to contain a Jinja expression that
296
+ renders to the same result.
297
+ """
282
298
  path = config_path(root)
299
+ document, layout = load_config_document(path)
300
+ if not update_config_document(document, config, set(explicit or [])):
301
+ # Nothing changed: leave the file alone, mtime included.
302
+ return
283
303
  utils.ensure_file_directory_exists(path)
284
304
  with open(path, "w", encoding="utf-8") as of:
285
- serialize.dump(config, of)
305
+ serialize.dump_round_trip(document, of, layout)
286
306
  fmt.echo_info(f"Configuration saved to {path}")
287
307
 
288
308
 
309
+ def load_config_document(path: str) -> tuple[t.Any, serialize.YamlLayout]:
310
+ """
311
+ Load config.yml as an editable document that remembers its own formatting.
312
+
313
+ An empty document is returned when the file does not exist yet, or when it
314
+ does not hold a mapping.
315
+ """
316
+ if not os.path.exists(path):
317
+ return serialize.empty_document(), serialize.YamlLayout()
318
+ with open(path, encoding="utf-8") as f:
319
+ document, layout = serialize.load_round_trip(f.read())
320
+ if not isinstance(document, dict):
321
+ return serialize.empty_document(), layout
322
+ return document, layout
323
+
324
+
325
+ def update_config_document(document: t.Any, config: Config, explicit: set[str]) -> bool:
326
+ """
327
+ Apply `config` to an existing config.yml document, in place.
328
+
329
+ Return True if the document was modified, and thus needs to be written back
330
+ to disk.
331
+ """
332
+ changed = False
333
+ for key, value in config.items():
334
+ if key not in document:
335
+ # New entry: append it at the end of the document.
336
+ document[key] = value
337
+ changed = True
338
+ continue
339
+ if document[key] == value:
340
+ continue
341
+ if key not in explicit and is_stable_expression(config, document[key]):
342
+ # The file holds a Jinja expression that is re-evaluated on every
343
+ # load, so the value we have in memory is derived data: saving it
344
+ # would replace the expression by one of its results. Leave it be.
345
+ continue
346
+ document[key] = value
347
+ changed = True
348
+ for key in [key for key in document if key not in config]:
349
+ del document[key]
350
+ changed = True
351
+ return changed
352
+
353
+
354
+ def is_stable_expression(config: Config, raw_value: t.Any) -> bool:
355
+ """
356
+ Check whether `raw_value`, as read from config.yml, is a Jinja expression
357
+ that always renders to the same result.
358
+
359
+ This is what allows a hand-written entry such as:
360
+
361
+ CMS_HOST: "studio.{{ LMS_HOST }}"
362
+
363
+ to survive a save, instead of being flattened to its result. Expressions
364
+ that render to something different every time, such as
365
+ "{{ 24|random_string }}", are not stable: those are frozen to a literal,
366
+ which is how generated secrets have always behaved.
367
+
368
+ Rendering happens against the final configuration, so an expression that
369
+ points at another entry keeps working even when that other entry just
370
+ changed.
371
+ """
372
+ try:
373
+ rendered = env.render_unknown(config, raw_value)
374
+ if rendered == raw_value:
375
+ # Not an expression at all, just a plain value.
376
+ return False
377
+ return bool(rendered == env.render_unknown(config, raw_value))
378
+ except exceptions.TutorError:
379
+ return False
380
+
381
+
289
382
  def config_path(root: str) -> str:
290
383
  return os.path.join(root, CONFIG_FILENAME)
291
384
 
@@ -1,3 +1,4 @@
1
+ from copy import deepcopy
1
2
  from typing import List, Optional
2
3
 
3
4
  import click
@@ -7,7 +8,7 @@ from . import env, exceptions, fmt, hooks
7
8
  from .types import Config, get_typed
8
9
 
9
10
 
10
- def ask_questions(config: Config, run_for_prod: Optional[bool] = None) -> None:
11
+ def ask_questions(config: Config, run_for_prod: Optional[bool] = None) -> List[str]:
11
12
  """
12
13
  Interactively ask questions to collect configuration values from the user.
13
14
 
@@ -16,8 +17,10 @@ def ask_questions(config: Config, run_for_prod: Optional[bool] = None) -> None:
16
17
  run_for_prod: Whether platform should be configured for production.
17
18
  If None, then ask the user.
18
19
  Returns:
19
- None
20
+ The names of the entries whose value was changed by the answers. Entries
21
+ whose default value was accepted are not part of this list.
20
22
  """
23
+ before = deepcopy(config)
21
24
  defaults = tutor_config.get_defaults()
22
25
  if run_for_prod is None:
23
26
  run_for_prod = config.get("LMS_HOST") not in [
@@ -154,6 +157,12 @@ def ask_questions(config: Config, run_for_prod: Optional[bool] = None) -> None:
154
157
 
155
158
  hooks.Actions.CONFIG_INTERACTIVE.do(config)
156
159
 
160
+ return [
161
+ key
162
+ for key in set(before) | set(config)
163
+ if key not in before or key not in config or before[key] != config[key]
164
+ ]
165
+
157
166
 
158
167
  def ask(question: str, key: str, config: Config, defaults: Config) -> None:
159
168
  default = get_typed(defaults, key, str)
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import typing as t
5
+ from _io import TextIOWrapper
6
+
7
+ import yaml
8
+ from ruamel.yaml import YAML
9
+ from ruamel.yaml.comments import CommentedMap
10
+ from ruamel.yaml.resolver import VersionedResolver
11
+ from ruamel.yaml.util import load_yaml_guess_indent
12
+ from yaml.parser import ParserError
13
+ from yaml.scanner import ScannerError
14
+
15
+
16
+ def load(stream: t.Union[str, t.IO[str]]) -> t.Any:
17
+ return yaml.load(stream, Loader=yaml.SafeLoader)
18
+
19
+
20
+ def load_all(stream: str) -> t.Iterator[t.Any]:
21
+ return yaml.load_all(stream, Loader=yaml.SafeLoader)
22
+
23
+
24
+ def dump_all(documents: t.Sequence[t.Any], fileobj: TextIOWrapper) -> None:
25
+ yaml.safe_dump_all(
26
+ documents, stream=fileobj, default_flow_style=False, allow_unicode=True
27
+ )
28
+
29
+
30
+ def dump(content: t.Any, fileobj: TextIOWrapper) -> None:
31
+ yaml.dump(content, stream=fileobj, default_flow_style=False, allow_unicode=True)
32
+
33
+
34
+ def dumps(content: t.Any) -> str:
35
+ result = yaml.dump(content, default_flow_style=False, allow_unicode=True)
36
+ assert isinstance(result, str)
37
+ return result
38
+
39
+
40
+ class YamlLayout(t.NamedTuple):
41
+ """
42
+ Layout of a yaml document that the round-trip loader does not carry inside
43
+ the document itself, and that must therefore be handed back to the dumper.
44
+ """
45
+
46
+ sequence_indent: int = 2
47
+ sequence_dash_offset: int = 0
48
+ explicit_start: bool = False
49
+
50
+
51
+ class Yaml11Resolver(VersionedResolver):
52
+ """
53
+ Resolve plain scalars with the YAML 1.1 rules, which are the ones used by
54
+ PyYAML, instead of the YAML 1.2 rules that ruamel.yaml applies by default.
55
+
56
+ Files that are written by the round-trip dumper are read back with PyYAML, so
57
+ both must agree on the type of every value. Under YAML 1.2, `on`, `yes` and
58
+ `0777` are a string, a string and the integer 777, while PyYAML loads them as
59
+ True, True and 511. With this resolver, the round-trip loader returns the same
60
+ values as PyYAML, and the dumper quotes any string that PyYAML would load as
61
+ something else.
62
+
63
+ Note that ruamel.yaml would also switch to YAML 1.1 if we set `YAML.version`,
64
+ but then every document would be dumped with a "%YAML 1.1" directive.
65
+ """
66
+
67
+ @property
68
+ def processing_version(self) -> t.Any:
69
+ return (1, 1)
70
+
71
+
72
+ def round_trip_yaml(layout: YamlLayout) -> YAML:
73
+ """
74
+ YAML parser/dumper that preserves comments, key order and quoting style.
75
+
76
+ This is considerably slower than the SafeLoader used everywhere else, so it
77
+ should only be used for the few files that users are expected to edit by
78
+ hand, such as config.yml.
79
+ """
80
+ yaml_rt = YAML(typ="rt")
81
+ yaml_rt.Resolver = Yaml11Resolver
82
+ # PyYAML keeps the last of duplicate keys, while ruamel.yaml refuses to load
83
+ # them. Accept them too: ruamel.yaml keeps the first value, but the save then
84
+ # sees that it differs from the one we loaded with PyYAML, and writes the
85
+ # latter back, once.
86
+ yaml_rt.allow_duplicate_keys = True
87
+ yaml_rt.preserve_quotes = True
88
+ yaml_rt.default_flow_style = False
89
+ yaml_rt.allow_unicode = True
90
+ yaml_rt.explicit_start = layout.explicit_start
91
+ yaml_rt.indent(
92
+ mapping=2,
93
+ sequence=layout.sequence_indent,
94
+ offset=layout.sequence_dash_offset,
95
+ )
96
+ # Never re-wrap long values, such as RSA private keys, onto several lines.
97
+ yaml_rt.width = 2**16
98
+ return yaml_rt
99
+
100
+
101
+ def load_round_trip(text: str) -> tuple[t.Any, YamlLayout]:
102
+ """
103
+ Load a yaml document, preserving comments, key order and quoting style.
104
+
105
+ Return the document, along with the layout that must be passed back to
106
+ `dump_round_trip` for the document to be written out as the user wrote it.
107
+ The layout does not affect parsing, so the default one is good enough to load
108
+ the document with.
109
+ """
110
+ document, sequence_indent, dash_offset = load_yaml_guess_indent(
111
+ text, yaml=round_trip_yaml(YamlLayout())
112
+ )
113
+ layout = YamlLayout(
114
+ sequence_indent=2 if sequence_indent is None else int(sequence_indent),
115
+ sequence_dash_offset=0 if dash_offset is None else int(dash_offset),
116
+ explicit_start=has_explicit_start(text),
117
+ )
118
+ return document, layout
119
+
120
+
121
+ def has_explicit_start(text: str) -> bool:
122
+ """
123
+ Check whether a yaml document opens with an explicit "---" marker.
124
+ """
125
+ for line in text.splitlines():
126
+ stripped = line.strip()
127
+ if not stripped or stripped.startswith("#"):
128
+ continue
129
+ return stripped == "---"
130
+ return False
131
+
132
+
133
+ def dump_round_trip(
134
+ content: t.Any, fileobj: t.IO[str], layout: YamlLayout = YamlLayout()
135
+ ) -> None:
136
+ """
137
+ Dump a document loaded with `load_round_trip`, preserving its formatting.
138
+ """
139
+ round_trip_yaml(layout).dump(content, fileobj)
140
+
141
+
142
+ def empty_document() -> CommentedMap:
143
+ """
144
+ An empty document suitable for `dump_round_trip`.
145
+ """
146
+ return CommentedMap()
147
+
148
+
149
+ def str_format(content: t.Any) -> str:
150
+ """
151
+ Convert a value to str.
152
+
153
+ This is almost like json, but more convenient for printing to the standard output.
154
+ """
155
+ if content is True:
156
+ return "true"
157
+ if content is False:
158
+ return "false"
159
+ if content is None:
160
+ return "null"
161
+ return str(content)
162
+
163
+
164
+ def parse(v: t.Union[str, t.IO[str]]) -> t.Any:
165
+ """
166
+ Parse a yaml-formatted string.
167
+ """
168
+ try:
169
+ return load(v)
170
+ except (ParserError, ScannerError):
171
+ pass
172
+ return v
173
+
174
+
175
+ def parse_key_value(text: str) -> t.Optional[tuple[str, t.Any]]:
176
+ """
177
+ Parse <KEY>=<YAML VALUE> command line arguments.
178
+
179
+ Return None if text could not be parsed.
180
+ """
181
+ match = re.match(r"(?P<key>[a-zA-Z0-9_-]+)=(?P<value>(.|\n|\r)*)", text)
182
+ if not match:
183
+ return None
184
+ key = match.groupdict()["key"]
185
+ value = match.groupdict()["value"]
186
+ if not value:
187
+ # Empty strings are interpreted as null values, which is incorrect.
188
+ value = "''"
189
+ elif "\n" not in value and value.startswith("#"):
190
+ # Single-line string that starts with a pound # key
191
+ # We need to escape the string, otherwise pound will be interpreted as a comment.
192
+ value = f'"{value}"'
193
+ return key, parse(value)
@@ -8,10 +8,10 @@ ENV DEBIAN_FRONTEND=noninteractive
8
8
  RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
9
9
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
10
10
  apt update && \
11
- apt install -y build-essential curl git language-pack-en
11
+ apt install -y build-essential ca-certificates curl git language-pack-en
12
12
  ENV LC_ALL=en_US.UTF-8
13
13
 
14
- COPY --from=ghcr.io/astral-sh/uv:0.9.18 /uv /usr/local/bin/uv
14
+ COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /usr/local/bin/uv
15
15
  ARG PIP_COMMAND="uv pip"
16
16
 
17
17
  # Constrain the build-time setuptools used in the isolated (PEP 517) build
@@ -23,6 +23,8 @@ ARG PIP_COMMAND="uv pip"
23
23
  RUN echo "setuptools==80.9.0" > /etc/openedx-build-constraints.txt
24
24
  ENV UV_BUILD_CONSTRAINT=/etc/openedx-build-constraints.txt
25
25
  ENV PIP_CONSTRAINT=/etc/openedx-build-constraints.txt
26
+ # pip 26 no longer applies PIP_CONSTRAINT to build dependencies
27
+ ENV PIP_BUILD_CONSTRAINT=/etc/openedx-build-constraints.txt
26
28
 
27
29
  {{ patch("openedx-dockerfile-minimal") }}
28
30
 
@@ -110,7 +112,7 @@ RUN --mount=type=cache,target=/openedx/.cache/pip,sharing=shared \
110
112
  # https://pypi.org/project/setuptools/
111
113
  # https://pypi.org/project/pip/
112
114
  # https://pypi.org/project/wheel/
113
- setuptools pip==24.0 wheel==0.43.0
115
+ setuptools pip==26.2.2 wheel==0.43.0
114
116
 
115
117
  # Install base requirements and asset-building requirements
116
118
  RUN --mount=type=bind,from=edx-platform,source=/pyproject.toml,target=/openedx/edx-platform/pyproject.toml \
@@ -118,6 +120,11 @@ RUN --mount=type=bind,from=edx-platform,source=/pyproject.toml,target=/openedx/e
118
120
  --mount=type=cache,target=/openedx/.cache/pip,sharing=shared \
119
121
  cd /openedx/edx-platform && uv sync --frozen --no-default-groups --group bundled --group assets --no-install-project --active
120
122
 
123
+ # Upgrade certifi until edx-platform pins a version without the compromised emSign roots
124
+ # https://emudhra.we-have-a-problem.com/
125
+ RUN --mount=type=cache,target=/openedx/.cache/pip,sharing=shared \
126
+ $PIP_COMMAND install "certifi>=2026.10.19"
127
+
121
128
  # Install extra requirements
122
129
  RUN --mount=type=cache,target=/openedx/.cache/pip,sharing=shared \
123
130
  $PIP_COMMAND install \
@@ -146,7 +153,7 @@ ENV PATH=/openedx/nodeenv/bin:/openedx/venv/bin:${PATH}
146
153
  # Install nodeenv with the version provided by edx-platform
147
154
  # https://github.com/openedx/edx-platform/blob/master/requirements/edx/assets.txt
148
155
  RUN $PIP_COMMAND install nodeenv==1.9.1
149
- RUN nodeenv /openedx/nodeenv --node=24.16.0 --prebuilt
156
+ RUN nodeenv /openedx/nodeenv --node=24.22.1 --prebuilt
150
157
 
151
158
  # Install nodejs requirements
152
159
  ARG NPM_REGISTRY={{ NPM_REGISTRY }}
@@ -293,6 +300,9 @@ USER app
293
300
  RUN --mount=type=cache,target=/openedx/.cache/pip,sharing=shared \
294
301
  uv export --frozen --no-hashes --no-default-groups --group development | \
295
302
  $PIP_COMMAND install -r /dev/stdin
303
+ # The development dependency group pins the old certifi again, so upgrade it one more time
304
+ RUN --mount=type=cache,target=/openedx/.cache/pip,sharing=shared \
305
+ $PIP_COMMAND install "certifi>=2026.10.19"
296
306
  # https://pypi.org/project/ipdb/
297
307
  # https://pypi.org/project/ipython (>=Python 3.10 started with 8.20)
298
308
  RUN --mount=type=cache,target=/openedx/.cache/pip,sharing=shared \
@@ -15,7 +15,7 @@ DOCKER_REGISTRY: "docker.io/"
15
15
  DOCKER_IMAGE_OPENEDX: "{{ DOCKER_REGISTRY }}overhangio/openedx:{{ TUTOR_VERSION }}"
16
16
  DOCKER_IMAGE_OPENEDX_DEV: "openedx-dev:{{ TUTOR_VERSION }}"
17
17
  # https://hub.docker.com/_/caddy/tags
18
- DOCKER_IMAGE_CADDY: "docker.io/caddy:2.11.4"
18
+ DOCKER_IMAGE_CADDY: "docker.io/caddy:2.11.7"
19
19
  # https://hub.docker.com/r/getmeili/meilisearch/tags
20
20
  DOCKER_IMAGE_MEILISEARCH: "docker.io/getmeili/meilisearch:v1.36.0"
21
21
  # https://hub.docker.com/_/mongo/tags
@@ -1,80 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- import typing as t
5
- from _io import TextIOWrapper
6
-
7
- import yaml
8
- from yaml.parser import ParserError
9
- from yaml.scanner import ScannerError
10
-
11
-
12
- def load(stream: t.Union[str, t.IO[str]]) -> t.Any:
13
- return yaml.load(stream, Loader=yaml.SafeLoader)
14
-
15
-
16
- def load_all(stream: str) -> t.Iterator[t.Any]:
17
- return yaml.load_all(stream, Loader=yaml.SafeLoader)
18
-
19
-
20
- def dump_all(documents: t.Sequence[t.Any], fileobj: TextIOWrapper) -> None:
21
- yaml.safe_dump_all(
22
- documents, stream=fileobj, default_flow_style=False, allow_unicode=True
23
- )
24
-
25
-
26
- def dump(content: t.Any, fileobj: TextIOWrapper) -> None:
27
- yaml.dump(content, stream=fileobj, default_flow_style=False, allow_unicode=True)
28
-
29
-
30
- def dumps(content: t.Any) -> str:
31
- result = yaml.dump(content, default_flow_style=False, allow_unicode=True)
32
- assert isinstance(result, str)
33
- return result
34
-
35
-
36
- def str_format(content: t.Any) -> str:
37
- """
38
- Convert a value to str.
39
-
40
- This is almost like json, but more convenient for printing to the standard output.
41
- """
42
- if content is True:
43
- return "true"
44
- if content is False:
45
- return "false"
46
- if content is None:
47
- return "null"
48
- return str(content)
49
-
50
-
51
- def parse(v: t.Union[str, t.IO[str]]) -> t.Any:
52
- """
53
- Parse a yaml-formatted string.
54
- """
55
- try:
56
- return load(v)
57
- except (ParserError, ScannerError):
58
- pass
59
- return v
60
-
61
-
62
- def parse_key_value(text: str) -> t.Optional[tuple[str, t.Any]]:
63
- """
64
- Parse <KEY>=<YAML VALUE> command line arguments.
65
-
66
- Return None if text could not be parsed.
67
- """
68
- match = re.match(r"(?P<key>[a-zA-Z0-9_-]+)=(?P<value>(.|\n|\r)*)", text)
69
- if not match:
70
- return None
71
- key = match.groupdict()["key"]
72
- value = match.groupdict()["value"]
73
- if not value:
74
- # Empty strings are interpreted as null values, which is incorrect.
75
- value = "''"
76
- elif "\n" not in value and value.startswith("#"):
77
- # Single-line string that starts with a pound # key
78
- # We need to escape the string, otherwise pound will be interpreted as a comment.
79
- value = f'"{value}"'
80
- return key, parse(value)