taskflow-meter 1.2.0__tar.gz → 1.3.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 (115) hide show
  1. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/PKG-INFO +1 -1
  2. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/_version.py +2 -2
  3. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/__init__.py +2 -1
  4. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/migrations/env.py +46 -15
  5. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/source.py +53 -5
  6. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/functional/test_migrations.py +73 -5
  7. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/.gitignore +0 -0
  8. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/LICENSE +0 -0
  9. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/README.md +0 -0
  10. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/docs/design.md +0 -0
  11. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/docs/guide.md +0 -0
  12. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/docs/releasing.md +0 -0
  13. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/pyproject.toml +0 -0
  14. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/__init__.py +0 -0
  15. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/__init__.py +0 -0
  16. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/asgi.py +0 -0
  17. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/dispatch.py +0 -0
  18. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/http.py +0 -0
  19. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/router.py +0 -0
  20. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/routes.py +0 -0
  21. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/serializers.py +0 -0
  22. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/service.py +0 -0
  23. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/sse.py +0 -0
  24. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/api/wsgi.py +0 -0
  25. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/cli.py +0 -0
  26. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/__init__.py +0 -0
  27. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/attachment.py +0 -0
  28. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/listener.py +0 -0
  29. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/pipeline.py +0 -0
  30. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/progress.py +0 -0
  31. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/conf.py +0 -0
  32. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/__init__.py +0 -0
  33. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/django.py +0 -0
  34. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/fastapi.py +0 -0
  35. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/flask.py +0 -0
  36. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/paste.py +0 -0
  37. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/pecan.py +0 -0
  38. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/__init__.py +0 -0
  39. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/base.py +0 -0
  40. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/memory.py +0 -0
  41. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/persistence.py +0 -0
  42. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/migrations/script.py.mako +0 -0
  43. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/migrations/versions/0001_initial.py +0 -0
  44. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/models.py +0 -0
  45. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/diff.py +0 -0
  46. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/events.py +0 -0
  47. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/fold.py +0 -0
  48. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/meter.py +0 -0
  49. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/models.py +0 -0
  50. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/poller.py +0 -0
  51. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/py.typed +0 -0
  52. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/states.py +0 -0
  53. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/__init__.py +0 -0
  54. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/amqp.py +0 -0
  55. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/base.py +0 -0
  56. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/http.py +0 -0
  57. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/memory.py +0 -0
  58. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/oslo_messaging.py +0 -0
  59. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/__init__.py +0 -0
  60. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/asgi_client.py +0 -0
  61. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/conformance/__init__.py +0 -0
  62. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/conformance/test_hosts.py +0 -0
  63. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/conformance/test_parity.py +0 -0
  64. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/conftest.py +0 -0
  65. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/functional/__init__.py +0 -0
  66. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/functional/test_cross_process.py +0 -0
  67. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/functional/test_examples.py +0 -0
  68. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/functional/test_packaging.py +0 -0
  69. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/functional/test_serve.py +0 -0
  70. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/functional/test_tooling.py +0 -0
  71. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/hosts.py +0 -0
  72. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/integration/__init__.py +0 -0
  73. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/integration/test_attach.py +0 -0
  74. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/integration/test_collector.py +0 -0
  75. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/__init__.py +0 -0
  76. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/api/__init__.py +0 -0
  77. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/api/test_asgi.py +0 -0
  78. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/api/test_http.py +0 -0
  79. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/api/test_router.py +0 -0
  80. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/api/test_routes.py +0 -0
  81. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/api/test_serializers.py +0 -0
  82. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/api/test_service.py +0 -0
  83. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/api/test_sse.py +0 -0
  84. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/api/test_wsgi.py +0 -0
  85. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/collect/__init__.py +0 -0
  86. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/collect/test_attachment.py +0 -0
  87. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/collect/test_listener.py +0 -0
  88. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/collect/test_pipeline.py +0 -0
  89. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/collect/test_progress.py +0 -0
  90. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/contrib/__init__.py +0 -0
  91. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_django.py +0 -0
  92. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_fastapi.py +0 -0
  93. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_flask.py +0 -0
  94. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_paste.py +0 -0
  95. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_pecan.py +0 -0
  96. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/datasource/__init__.py +0 -0
  97. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/datasource/sqlalchemy/__init__.py +0 -0
  98. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/datasource/sqlalchemy/test_source.py +0 -0
  99. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/datasource/test_base.py +0 -0
  100. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/datasource/test_memory.py +0 -0
  101. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/datasource/test_persistence.py +0 -0
  102. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/test_cli.py +0 -0
  103. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/test_conf.py +0 -0
  104. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/test_diff.py +0 -0
  105. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/test_events.py +0 -0
  106. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/test_meter.py +0 -0
  107. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/test_models.py +0 -0
  108. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/test_poller.py +0 -0
  109. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/test_states.py +0 -0
  110. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/transports/__init__.py +0 -0
  111. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/transports/test_amqp.py +0 -0
  112. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/transports/test_http.py +0 -0
  113. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/transports/test_memory.py +0 -0
  114. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/unit/transports/test_oslo_messaging.py +0 -0
  115. {taskflow_meter-1.2.0 → taskflow_meter-1.3.0}/tests/wsgi_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskflow-meter
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Monitoring interfaces (ASGI, WSGI, datasource) for OpenStack TaskFlow flow execution progress
5
5
  Project-URL: Homepage, https://github.com/daipham3213/taskflow.meter
6
6
  Project-URL: Source, https://github.com/daipham3213/taskflow.meter
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.2.0'
22
- __version_tuple__ = version_tuple = (1, 2, 0)
21
+ __version__ = version = '1.3.0'
22
+ __version_tuple__ = version_tuple = (1, 3, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  from taskflow_meter.datasource.sqlalchemy.models import metadata
18
18
  from taskflow_meter.datasource.sqlalchemy.source import SQLADataSource
19
+ from taskflow_meter.datasource.sqlalchemy.source import alembic_config
19
20
  from taskflow_meter.datasource.sqlalchemy.source import upgrade
20
21
 
21
- __all__ = ["SQLADataSource", "metadata", "upgrade"]
22
+ __all__ = ["SQLADataSource", "alembic_config", "metadata", "upgrade"]
@@ -13,16 +13,21 @@
13
13
  """Alembic environment for the meter's own schema.
14
14
 
15
15
  Driven programmatically by :func:`taskflow_meter.datasource.sqlalchemy
16
- .upgrade`, so the URL arrives through the config rather than from an
17
- ``alembic.ini`` a deployment would have to carry.
16
+ .upgrade`, so the connection arrives through the config rather than from
17
+ an ``alembic.ini`` a deployment would have to carry.
18
+
19
+ Everything variable is read from ``config.attributes``, never from a
20
+ main option: alembic passes main options through configparser, which
21
+ treats ``%`` as an interpolation and rejects a percent-encoded password.
22
+ See :func:`~taskflow_meter.datasource.sqlalchemy.source.alembic_config`.
18
23
  """
19
24
 
20
25
  from __future__ import annotations
21
26
 
22
27
  from typing import Any
23
28
 
29
+ import sqlalchemy as sa
24
30
  from alembic import context
25
- from sqlalchemy import engine_from_config
26
31
  from sqlalchemy import pool
27
32
 
28
33
  from taskflow_meter.datasource.sqlalchemy.models import metadata
@@ -39,12 +44,26 @@ DEFAULT_VERSION_TABLE = "alembic_version"
39
44
 
40
45
 
41
46
  def _version_table() -> str:
42
- return config.get_main_option("version_table", DEFAULT_VERSION_TABLE)
47
+ return config.attributes.get("version_table") or DEFAULT_VERSION_TABLE
48
+
49
+
50
+ def _url() -> str | None:
51
+ """Where to connect, when no live connection was handed over.
52
+
53
+ Falls back to the main option so that running these migrations from
54
+ a hand-written ``alembic.ini`` still works. Escaping ``%`` is the
55
+ ini author's problem there, and ``%%`` is the documented convention
56
+ for it.
57
+ """
58
+ url = config.attributes.get("url")
59
+ if url is not None:
60
+ return str(url)
61
+ return config.get_main_option("sqlalchemy.url", None)
43
62
 
44
63
 
45
64
  def run_migrations_offline() -> None:
46
65
  context.configure(
47
- url=config.get_main_option("sqlalchemy.url"),
66
+ url=_url(),
48
67
  target_metadata=target_metadata,
49
68
  literal_binds=True,
50
69
  dialect_opts={"paramstyle": "named"},
@@ -55,17 +74,29 @@ def run_migrations_offline() -> None:
55
74
 
56
75
 
57
76
  def run_migrations_online() -> None:
58
- connectable = config.attributes.get("connection", None)
59
- if connectable is None:
60
- connectable = engine_from_config(
61
- config.get_section(config.config_ini_section, {}),
62
- prefix="sqlalchemy.",
63
- poolclass=pool.NullPool,
64
- )
65
- with connectable.connect() as connection:
66
- _run(connection)
77
+ connection = config.attributes.get("connection")
78
+ if connection is not None:
79
+ # The usual path: somebody else owns the connection and the
80
+ # transaction around it.
81
+ _run(connection)
67
82
  return
68
- _run(connectable)
83
+
84
+ url = _url()
85
+ if url is None:
86
+ # Reached only by a config built by hand that set neither.
87
+ # Saying so beats whatever SQLAlchemy makes of ``None``.
88
+ msg = (
89
+ "no connection and no url to migrate: build the config with "
90
+ "taskflow_meter.datasource.sqlalchemy.alembic_config()"
91
+ )
92
+ raise RuntimeError(msg)
93
+
94
+ engine = sa.create_engine(url, poolclass=pool.NullPool)
95
+ try:
96
+ with engine.connect() as owned:
97
+ _run(owned)
98
+ finally:
99
+ engine.dispose()
69
100
 
70
101
 
71
102
  def _run(connection: Any) -> None:
@@ -30,10 +30,14 @@ from collections.abc import Iterable
30
30
  from collections.abc import Iterator
31
31
  from dataclasses import asdict
32
32
  from pathlib import Path
33
+ from typing import TYPE_CHECKING
33
34
  from typing import Any
34
35
 
35
36
  import sqlalchemy as sa
36
37
 
38
+ if TYPE_CHECKING:
39
+ from alembic.config import Config
40
+
37
41
  from taskflow_meter.datasource.base import DEFAULT_EVENT_LIMIT
38
42
  from taskflow_meter.datasource.base import DEFAULT_FLOW_LIMIT
39
43
  from taskflow_meter.datasource.base import EventPage
@@ -371,11 +375,55 @@ def upgrade(
371
375
  database -- changing it later looks exactly like an unmigrated one.
372
376
  """
373
377
  from alembic import command
378
+
379
+ engine = sa.create_engine(url)
380
+ try:
381
+ # One live connection, handed over in ``attributes``. The URL is
382
+ # parsed by SQLAlchemy, which understands URLs, and never by
383
+ # configparser, which does not.
384
+ with engine.begin() as connection:
385
+ config = alembic_config(
386
+ connection=connection, version_table=version_table
387
+ )
388
+ command.upgrade(config, revision)
389
+ finally:
390
+ engine.dispose()
391
+
392
+
393
+ def alembic_config(
394
+ *,
395
+ url: str | None = None,
396
+ connection: sa.Connection | None = None,
397
+ version_table: str | None = None,
398
+ ) -> Config:
399
+ """Build the config this package's migrations run under.
400
+
401
+ Public because ``upgrade`` is not the only thing an operator runs:
402
+ ``downgrade``, ``stamp`` and ``--sql`` mode all need a config, and
403
+ building one by hand is how the escaping rule below gets forgotten.
404
+
405
+ Everything variable travels in ``config.attributes`` rather than as
406
+ a main option, because alembic hands main options to configparser,
407
+ which reads ``%`` as the start of an interpolation and rejects
408
+ anything that is not one. Escaping it to ``%%`` does work, but
409
+ only for whoever remembers; keeping the value out of configparser
410
+ altogether cannot be forgotten.
411
+
412
+ Pass *connection* for a live migration, or *url* for ``--sql`` mode,
413
+ which has no connection to offer.
414
+ """
374
415
  from alembic.config import Config
375
416
 
376
417
  config = Config()
377
- config.set_main_option("script_location", str(MIGRATIONS))
378
- config.set_main_option("sqlalchemy.url", url)
379
- if version_table is not None:
380
- config.set_main_option("version_table", version_table)
381
- command.upgrade(config, revision)
418
+ # script_location has to be a main option -- alembic reads it from
419
+ # there to find the scripts. It is a path inside this package
420
+ # rather than anything a caller supplies, but an install prefix
421
+ # containing "%" is unusual rather than impossible, so it gets the
422
+ # escaping that the URL no longer needs.
423
+ config.set_main_option(
424
+ "script_location", str(MIGRATIONS).replace("%", "%%")
425
+ )
426
+ config.attributes["url"] = url
427
+ config.attributes["connection"] = connection
428
+ config.attributes["version_table"] = version_table
429
+ return config
@@ -25,18 +25,20 @@ import sqlalchemy as sa
25
25
  from alembic import command
26
26
  from alembic.config import Config
27
27
 
28
- from taskflow_meter.datasource.sqlalchemy.source import MIGRATIONS
29
28
  from taskflow_meter.datasource.sqlalchemy.source import SQLADataSource
29
+ from taskflow_meter.datasource.sqlalchemy.source import alembic_config
30
30
  from taskflow_meter.datasource.sqlalchemy.source import upgrade
31
31
  from taskflow_meter.events import Event
32
32
  from taskflow_meter.events import EventKind
33
33
 
34
34
 
35
35
  def config_for(url: str) -> Config:
36
- config = Config()
37
- config.set_main_option("script_location", str(MIGRATIONS))
38
- config.set_main_option("sqlalchemy.url", url)
39
- return config
36
+ """What an operator building their own config should call.
37
+
38
+ A second, hand-rolled config here is how the tests would stop
39
+ covering the one that ships.
40
+ """
41
+ return alembic_config(url=url)
40
42
 
41
43
 
42
44
  def tables_of(url: str) -> set[str]:
@@ -61,6 +63,20 @@ def test_upgrading_creates_the_tables(url: str) -> None:
61
63
  }
62
64
 
63
65
 
66
+ def test_a_percent_encoded_url_is_not_read_as_interpolation(
67
+ tmp_path: Path,
68
+ ) -> None:
69
+ """``Fci%40123%21`` is a password, not a configparser substitution."""
70
+ url = f"sqlite:///{tmp_path / 'me%40ter.db'}"
71
+
72
+ upgrade(url)
73
+
74
+ assert tables_of(url) == {
75
+ "taskflow_meter_flows",
76
+ "taskflow_meter_events",
77
+ }
78
+
79
+
64
80
  def test_downgrading_removes_them_again(url: str) -> None:
65
81
  # A migration whose downgrade is wrong is worse than one with none:
66
82
  # it fails halfway through somebody's rollback.
@@ -99,3 +115,55 @@ def test_the_revision_is_recorded(url: str) -> None:
99
115
  sa.text("SELECT version_num FROM alembic_version")
100
116
  ).scalar()
101
117
  assert version == "0001"
118
+
119
+
120
+ def test_offline_sql_also_survives_a_percent_encoded_url(
121
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
122
+ ) -> None:
123
+ """The half the old ``%%`` escape did not reach.
124
+
125
+ ``upgrade`` escaped the URL itself, so it coped; anyone building a
126
+ config for ``--sql`` got a ValueError out of configparser instead.
127
+ """
128
+ url = f"sqlite:///{tmp_path / 'me%40ter.db'}"
129
+
130
+ command.upgrade(alembic_config(url=url), "head", sql=True)
131
+
132
+ assert "CREATE TABLE taskflow_meter_flows" in capsys.readouterr().out
133
+
134
+
135
+ def test_the_url_never_reaches_configparser() -> None:
136
+ """The structural reason the percent tests pass.
137
+
138
+ Not an implementation detail: the moment a URL goes back into a
139
+ main option, every percent-encoded password breaks again.
140
+ """
141
+ url = "mysql+pymysql://u:abc%40123%21%40%23@127.0.0.1:3306/grimoire"
142
+
143
+ config = alembic_config(url=url)
144
+
145
+ assert config.attributes["url"] == url
146
+ assert config.get_main_option("sqlalchemy.url", None) is None
147
+ # And nothing in the ini section carries it either.
148
+ section = dict(config.get_section(config.config_ini_section, {}))
149
+ assert not any(url in str(value) for value in section.values())
150
+
151
+
152
+ def test_a_percent_encoded_url_needs_no_escaping_by_the_caller() -> None:
153
+ """The whole point: hand it over exactly as it arrived."""
154
+ url = "mysql+pymysql://u:abc%40123%21%40%23@db.internal:3306/grimoire"
155
+ assert alembic_config(url=url).attributes["url"] == url
156
+
157
+
158
+ def test_a_borrowed_connection_is_used_rather_than_a_new_one(
159
+ url: str,
160
+ ) -> None:
161
+ """``upgrade`` hands alembic a live connection, not a URL to open."""
162
+ upgrade(url)
163
+
164
+ engine = sa.create_engine(url)
165
+ with engine.begin() as connection:
166
+ command.downgrade(alembic_config(connection=connection), "base")
167
+ engine.dispose()
168
+
169
+ assert tables_of(url) == set()
File without changes
File without changes