plain.models 0.35.0__tar.gz → 0.37.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 (132) hide show
  1. {plain_models-0.35.0 → plain_models-0.37.0}/.gitignore +1 -2
  2. {plain_models-0.35.0 → plain_models-0.37.0}/PKG-INFO +1 -1
  3. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/CHANGELOG.md +22 -0
  4. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/utils.py +13 -10
  5. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backups/cli.py +2 -4
  6. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/cli.py +5 -114
  7. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/questioner.py +0 -14
  8. plain_models-0.37.0/plain/models/otel.py +175 -0
  9. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/test/pytest.py +16 -12
  10. plain_models-0.37.0/plain/models/test/utils.py +14 -0
  11. {plain_models-0.35.0 → plain_models-0.37.0}/pyproject.toml +1 -1
  12. plain_models-0.35.0/plain/models/test/utils.py +0 -11
  13. {plain_models-0.35.0 → plain_models-0.37.0}/LICENSE +0 -0
  14. {plain_models-0.35.0 → plain_models-0.37.0}/README.md +0 -0
  15. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/README.md +0 -0
  16. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/__init__.py +0 -0
  17. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/aggregates.py +0 -0
  18. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/__init__.py +0 -0
  19. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/base/__init__.py +0 -0
  20. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/base/base.py +0 -0
  21. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/base/client.py +0 -0
  22. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/base/creation.py +0 -0
  23. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/base/features.py +0 -0
  24. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/base/introspection.py +0 -0
  25. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/base/operations.py +0 -0
  26. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/base/schema.py +0 -0
  27. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/base/validation.py +0 -0
  28. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/ddl_references.py +0 -0
  29. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/__init__.py +0 -0
  30. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/base.py +0 -0
  31. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/client.py +0 -0
  32. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/compiler.py +0 -0
  33. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/creation.py +0 -0
  34. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/features.py +0 -0
  35. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/introspection.py +0 -0
  36. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/operations.py +0 -0
  37. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/schema.py +0 -0
  38. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/mysql/validation.py +0 -0
  39. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/postgresql/__init__.py +0 -0
  40. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/postgresql/base.py +0 -0
  41. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/postgresql/client.py +0 -0
  42. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/postgresql/creation.py +0 -0
  43. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/postgresql/features.py +0 -0
  44. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/postgresql/introspection.py +0 -0
  45. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/postgresql/operations.py +0 -0
  46. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/postgresql/schema.py +0 -0
  47. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/sqlite3/__init__.py +0 -0
  48. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/sqlite3/_functions.py +0 -0
  49. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/sqlite3/base.py +0 -0
  50. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/sqlite3/client.py +0 -0
  51. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/sqlite3/creation.py +0 -0
  52. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/sqlite3/features.py +0 -0
  53. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/sqlite3/introspection.py +0 -0
  54. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/sqlite3/operations.py +0 -0
  55. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backends/sqlite3/schema.py +0 -0
  56. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backups/__init__.py +0 -0
  57. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backups/clients.py +0 -0
  58. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/backups/core.py +0 -0
  59. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/base.py +0 -0
  60. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/config.py +0 -0
  61. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/connections.py +0 -0
  62. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/constants.py +0 -0
  63. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/constraints.py +0 -0
  64. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/database_url.py +0 -0
  65. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/db.py +0 -0
  66. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/default_settings.py +0 -0
  67. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/deletion.py +0 -0
  68. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/entrypoints.py +0 -0
  69. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/enums.py +0 -0
  70. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/exceptions.py +0 -0
  71. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/expressions.py +0 -0
  72. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/fields/__init__.py +0 -0
  73. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/fields/json.py +0 -0
  74. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/fields/mixins.py +0 -0
  75. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/fields/related.py +0 -0
  76. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/fields/related_descriptors.py +0 -0
  77. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/fields/related_lookups.py +0 -0
  78. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/fields/reverse_related.py +0 -0
  79. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/forms.py +0 -0
  80. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/functions/__init__.py +0 -0
  81. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/functions/comparison.py +0 -0
  82. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/functions/datetime.py +0 -0
  83. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/functions/math.py +0 -0
  84. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/functions/mixins.py +0 -0
  85. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/functions/text.py +0 -0
  86. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/functions/window.py +0 -0
  87. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/indexes.py +0 -0
  88. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/lookups.py +0 -0
  89. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/manager.py +0 -0
  90. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/__init__.py +0 -0
  91. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/autodetector.py +0 -0
  92. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/exceptions.py +0 -0
  93. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/executor.py +0 -0
  94. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/graph.py +0 -0
  95. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/loader.py +0 -0
  96. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/migration.py +0 -0
  97. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/operations/__init__.py +0 -0
  98. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/operations/base.py +0 -0
  99. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/operations/fields.py +0 -0
  100. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/operations/models.py +0 -0
  101. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/operations/special.py +0 -0
  102. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/optimizer.py +0 -0
  103. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/recorder.py +0 -0
  104. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/serializer.py +0 -0
  105. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/state.py +0 -0
  106. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/utils.py +0 -0
  107. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/migrations/writer.py +0 -0
  108. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/options.py +0 -0
  109. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/preflight.py +0 -0
  110. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/query.py +0 -0
  111. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/query_utils.py +0 -0
  112. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/registry.py +0 -0
  113. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/sql/__init__.py +0 -0
  114. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/sql/compiler.py +0 -0
  115. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/sql/constants.py +0 -0
  116. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/sql/datastructures.py +0 -0
  117. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/sql/query.py +0 -0
  118. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/sql/subqueries.py +0 -0
  119. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/sql/where.py +0 -0
  120. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/test/__init__.py +0 -0
  121. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/transaction.py +0 -0
  122. {plain_models-0.35.0 → plain_models-0.37.0}/plain/models/utils.py +0 -0
  123. {plain_models-0.35.0 → plain_models-0.37.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  124. {plain_models-0.35.0 → plain_models-0.37.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  125. {plain_models-0.35.0 → plain_models-0.37.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  126. {plain_models-0.35.0 → plain_models-0.37.0}/tests/app/examples/migrations/__init__.py +0 -0
  127. {plain_models-0.35.0 → plain_models-0.37.0}/tests/app/examples/models.py +0 -0
  128. {plain_models-0.35.0 → plain_models-0.37.0}/tests/app/settings.py +0 -0
  129. {plain_models-0.35.0 → plain_models-0.37.0}/tests/app/urls.py +0 -0
  130. {plain_models-0.35.0 → plain_models-0.37.0}/tests/test_database_url.py +0 -0
  131. {plain_models-0.35.0 → plain_models-0.37.0}/tests/test_delete_behaviors.py +0 -0
  132. {plain_models-0.35.0 → plain_models-0.37.0}/tests/test_models.py +0 -0
@@ -4,7 +4,6 @@
4
4
  *.py[co]
5
5
  __pycache__
6
6
  *.DS_Store
7
- .coverage
8
7
 
9
8
  # Test apps
10
9
  plain*/tests/.plain
@@ -17,4 +16,4 @@ plain*/tests/.plain
17
16
  # Plain temp dirs
18
17
  .plain
19
18
 
20
- coverage.xml
19
+ .vscode
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.models
3
- Version: 0.35.0
3
+ Version: 0.37.0
4
4
  Summary: Database models for Plain.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,27 @@
1
1
  # plain-models changelog
2
2
 
3
+ ## [0.37.0](https://github.com/dropseed/plain/releases/plain-models@0.37.0) (2025-07-18)
4
+
5
+ ### What's changed
6
+
7
+ - Added OpenTelemetry instrumentation for database operations - all SQL queries now automatically generate OpenTelemetry spans with standardized attributes following semantic conventions ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0))
8
+ - Database operations in tests are now wrapped with tracing suppression to avoid generating telemetry noise during test execution ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
14
+ ## [0.36.0](https://github.com/dropseed/plain/releases/plain-models@0.36.0) (2025-07-18)
15
+
16
+ ### What's changed
17
+
18
+ - Removed the `--merge` option from the `makemigrations` command ([d366663](https://github.com/dropseed/plain/commit/d366663))
19
+ - Improved error handling in the `restore-backup` command using Click's error system ([88f06c5](https://github.com/dropseed/plain/commit/88f06c5))
20
+
21
+ ### Upgrade instructions
22
+
23
+ - No changes required
24
+
3
25
  ## [0.35.0](https://github.com/dropseed/plain/releases/plain-models@0.35.0) (2025-07-07)
4
26
 
5
27
  ### What's changed
@@ -7,6 +7,7 @@ from contextlib import contextmanager
7
7
  from hashlib import md5
8
8
 
9
9
  from plain.models.db import NotSupportedError
10
+ from plain.models.otel import db_span
10
11
  from plain.utils.dateparse import parse_time
11
12
 
12
13
  logger = logging.getLogger("plain.models.backends")
@@ -80,18 +81,20 @@ class CursorWrapper:
80
81
  return executor(sql, params, many, context)
81
82
 
82
83
  def _execute(self, sql, params, *ignored_wrapper_args):
83
- self.db.validate_no_broken_transaction()
84
- with self.db.wrap_database_errors:
85
- if params is None:
86
- # params default might be backend specific.
87
- return self.cursor.execute(sql)
88
- else:
89
- return self.cursor.execute(sql, params)
84
+ # Wrap in an OpenTelemetry span with standard attributes.
85
+ with db_span(self.db, sql, params=params):
86
+ self.db.validate_no_broken_transaction()
87
+ with self.db.wrap_database_errors:
88
+ if params is None:
89
+ return self.cursor.execute(sql)
90
+ else:
91
+ return self.cursor.execute(sql, params)
90
92
 
91
93
  def _executemany(self, sql, param_list, *ignored_wrapper_args):
92
- self.db.validate_no_broken_transaction()
93
- with self.db.wrap_database_errors:
94
- return self.cursor.executemany(sql, param_list)
94
+ with db_span(self.db, sql, many=True, params=param_list):
95
+ self.db.validate_no_broken_transaction()
96
+ with self.db.wrap_database_errors:
97
+ return self.cursor.executemany(sql, param_list)
95
98
 
96
99
 
97
100
  class CursorDebugWrapper(CursorWrapper):
@@ -66,12 +66,10 @@ def restore_backup(backup_name, latest, pg_restore):
66
66
  backups_handler = DatabaseBackups()
67
67
 
68
68
  if backup_name and latest:
69
- click.secho("Only one of --latest or backup_name is allowed", fg="red")
70
- exit(1)
69
+ raise click.UsageError("Only one of --latest or backup_name is allowed")
71
70
 
72
71
  if not backup_name and not latest:
73
- click.secho("Backup name or --latest is required", fg="red")
74
- exit(1)
72
+ raise click.UsageError("Backup name or --latest is required")
75
73
 
76
74
  if not backup_name and latest:
77
75
  backup_name = backups_handler.find_backups()[0].name
@@ -2,7 +2,6 @@ import os
2
2
  import subprocess
3
3
  import sys
4
4
  import time
5
- from itertools import takewhile
6
5
 
7
6
  import click
8
7
 
@@ -22,12 +21,10 @@ from .migrations.migration import Migration, SettingsTuple
22
21
  from .migrations.optimizer import MigrationOptimizer
23
22
  from .migrations.questioner import (
24
23
  InteractiveMigrationQuestioner,
25
- MigrationQuestioner,
26
24
  NonInteractiveMigrationQuestioner,
27
25
  )
28
26
  from .migrations.recorder import MigrationRecorder
29
27
  from .migrations.state import ModelState, ProjectState
30
- from .migrations.utils import get_migration_name_timestamp
31
28
  from .migrations.writer import MigrationWriter
32
29
  from .registry import models_registry
33
30
 
@@ -92,7 +89,7 @@ def db_wait():
92
89
  )
93
90
  time.sleep(1.5)
94
91
  else:
95
- click.secho("Database ready", fg="green")
92
+ click.secho("Database ready", fg="green")
96
93
  break
97
94
 
98
95
 
@@ -135,7 +132,6 @@ def list_models(package_labels, app_only):
135
132
  is_flag=True,
136
133
  help="Just show what migrations would be made; don't actually write them.",
137
134
  )
138
- @click.option("--merge", is_flag=True, help="Enable fixing of migration conflicts.")
139
135
  @click.option("--empty", is_flag=True, help="Create an empty migration.")
140
136
  @click.option(
141
137
  "--noinput",
@@ -157,9 +153,7 @@ def list_models(package_labels, app_only):
157
153
  default=1,
158
154
  help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
159
155
  )
160
- def makemigrations(
161
- package_labels, dry_run, merge, empty, no_input, name, check, verbosity
162
- ):
156
+ def makemigrations(package_labels, dry_run, empty, no_input, name, check, verbosity):
163
157
  """Creates new migration(s) for packages."""
164
158
 
165
159
  written_files = []
@@ -226,103 +220,6 @@ def makemigrations(
226
220
  )
227
221
  log(writer.as_string(), level=3)
228
222
 
229
- def handle_merge(loader, conflicts):
230
- """Handle merging conflicting migrations."""
231
- if interactive:
232
- questioner = InteractiveMigrationQuestioner()
233
- else:
234
- questioner = MigrationQuestioner(defaults={"ask_merge": True})
235
-
236
- for package_label, migration_names in conflicts.items():
237
- log(click.style(f"Merging {package_label}", fg="cyan", bold=True), level=1)
238
-
239
- merge_migrations = []
240
- for migration_name in migration_names:
241
- migration = loader.get_migration(package_label, migration_name)
242
- migration.ancestry = [
243
- mig
244
- for mig in loader.graph.forwards_plan(
245
- (package_label, migration_name)
246
- )
247
- if mig[0] == migration.package_label
248
- ]
249
- merge_migrations.append(migration)
250
-
251
- def all_items_equal(seq):
252
- return all(item == seq[0] for item in seq[1:])
253
-
254
- merge_migrations_generations = zip(*(m.ancestry for m in merge_migrations))
255
- common_ancestor_count = sum(
256
- 1 for _ in takewhile(all_items_equal, merge_migrations_generations)
257
- )
258
- if not common_ancestor_count:
259
- raise ValueError(f"Could not find common ancestor of {migration_names}")
260
-
261
- for migration in merge_migrations:
262
- migration.branch = migration.ancestry[common_ancestor_count:]
263
- migrations_ops = (
264
- loader.get_migration(node_package, node_name).operations
265
- for node_package, node_name in migration.branch
266
- )
267
- migration.merged_operations = sum(migrations_ops, [])
268
-
269
- for migration in merge_migrations:
270
- log(click.style(f" Branch {migration.name}", fg="yellow"), level=1)
271
- for operation in migration.merged_operations:
272
- log(f" - {operation.describe()}", level=1)
273
-
274
- if questioner.ask_merge(package_label):
275
- numbers = [
276
- MigrationAutodetector.parse_number(migration.name)
277
- for migration in merge_migrations
278
- ]
279
- biggest_number = (
280
- max(x for x in numbers if x is not None) if numbers else 0
281
- )
282
-
283
- subclass = type(
284
- "Migration",
285
- (Migration,),
286
- {
287
- "dependencies": [
288
- (package_label, migration.name)
289
- for migration in merge_migrations
290
- ],
291
- },
292
- )
293
-
294
- parts = [f"{biggest_number + 1:04d}"]
295
- if migration_name:
296
- parts.append(migration_name)
297
- else:
298
- parts.append("merge")
299
- leaf_names = "_".join(
300
- sorted(migration.name for migration in merge_migrations)
301
- )
302
- if len(leaf_names) > 47:
303
- parts.append(get_migration_name_timestamp())
304
- else:
305
- parts.append(leaf_names)
306
-
307
- new_migration_name = "_".join(parts)
308
- new_migration = subclass(new_migration_name, package_label)
309
- writer = MigrationWriter(new_migration)
310
-
311
- if not dry_run:
312
- with open(writer.path, "w", encoding="utf-8") as fh:
313
- fh.write(writer.as_string())
314
- log(f"\nCreated new merge migration {writer.path}", level=1)
315
- elif verbosity == 3:
316
- log(
317
- click.style(
318
- f"Full merge migrations file '{writer.filename}':",
319
- fg="cyan",
320
- bold=True,
321
- ),
322
- level=3,
323
- )
324
- log(writer.as_string(), level=3)
325
-
326
223
  # Validate package labels
327
224
  package_labels = set(package_labels)
328
225
  has_bad_labels = False
@@ -351,21 +248,16 @@ def makemigrations(
351
248
  if package_label in package_labels
352
249
  }
353
250
 
354
- if conflicts and not merge:
251
+ if conflicts:
355
252
  name_str = "; ".join(
356
253
  "{} in {}".format(", ".join(names), package)
357
254
  for package, names in conflicts.items()
358
255
  )
359
256
  raise click.ClickException(
360
257
  f"Conflicting migrations detected; multiple leaf nodes in the "
361
- f"migration graph: ({name_str}).\nTo fix them run "
362
- f"'python manage.py makemigrations --merge'"
258
+ f"migration graph: ({name_str})."
363
259
  )
364
260
 
365
- # Handle merge if requested
366
- if merge and conflicts:
367
- return handle_merge(loader, conflicts)
368
-
369
261
  # Set up questioner
370
262
  if interactive:
371
263
  questioner = InteractiveMigrationQuestioner(
@@ -533,8 +425,7 @@ def migrate(
533
425
  )
534
426
  raise click.ClickException(
535
427
  "Conflicting migrations detected; multiple leaf nodes in the "
536
- f"migration graph: ({name_str}).\nTo fix them run "
537
- "'python manage.py makemigrations --merge'"
428
+ f"migration graph: ({name_str})."
538
429
  )
539
430
 
540
431
  # If they supplied command line arguments, work out what they mean.
@@ -74,10 +74,6 @@ class MigrationQuestioner:
74
74
  """Was this model really renamed?"""
75
75
  return self.defaults.get("ask_rename_model", False)
76
76
 
77
- def ask_merge(self, package_label):
78
- """Should these migrations really be merged?"""
79
- return self.defaults.get("ask_merge", False)
80
-
81
77
  def ask_auto_now_add_addition(self, field_name, model_name):
82
78
  """Adding an auto_now_add field to a model."""
83
79
  # None means quit
@@ -227,16 +223,6 @@ class InteractiveMigrationQuestioner(MigrationQuestioner):
227
223
  default=False,
228
224
  )
229
225
 
230
- def ask_merge(self, package_label):
231
- return self._boolean_input(
232
- (
233
- "\nMerging will only work if the operations printed above do not conflict\n"
234
- "with each other (working on different fields or models)\n"
235
- "Should these migration branches be merged?"
236
- ),
237
- default=False,
238
- )
239
-
240
226
  def ask_auto_now_add_addition(self, field_name, model_name):
241
227
  """Adding an auto_now_add field to a model."""
242
228
  if not self.dry_run:
@@ -0,0 +1,175 @@
1
+ import re
2
+ from contextlib import contextmanager
3
+ from typing import Any
4
+
5
+ from opentelemetry import context as otel_context
6
+ from opentelemetry import trace
7
+ from opentelemetry.semconv._incubating.attributes.db_attributes import (
8
+ DB_QUERY_PARAMETER_TEMPLATE,
9
+ DB_USER,
10
+ )
11
+ from opentelemetry.semconv.attributes.db_attributes import (
12
+ DB_COLLECTION_NAME,
13
+ DB_NAMESPACE,
14
+ DB_OPERATION_NAME,
15
+ DB_QUERY_SUMMARY,
16
+ DB_QUERY_TEXT,
17
+ DB_SYSTEM_NAME,
18
+ )
19
+ from opentelemetry.semconv.attributes.network_attributes import (
20
+ NETWORK_PEER_ADDRESS,
21
+ NETWORK_PEER_PORT,
22
+ )
23
+ from opentelemetry.semconv.trace import DbSystemValues
24
+ from opentelemetry.trace import SpanKind
25
+
26
+ from plain.runtime import settings
27
+
28
+ _SUPPRESS_KEY = object()
29
+
30
+ tracer = trace.get_tracer("plain.models")
31
+
32
+
33
+ def db_system_for(vendor: str) -> str: # noqa: D401 – simple helper
34
+ """Return the canonical ``db.system.name`` value for a backend vendor."""
35
+
36
+ return {
37
+ "postgresql": DbSystemValues.POSTGRESQL.value,
38
+ "mysql": DbSystemValues.MYSQL.value,
39
+ "mariadb": DbSystemValues.MARIADB.value,
40
+ "sqlite": DbSystemValues.SQLITE.value,
41
+ }.get(vendor, vendor)
42
+
43
+
44
+ def extract_operation_and_target(sql: str) -> tuple[str, str | None, str | None]:
45
+ """Extract operation, table name, and collection from SQL.
46
+
47
+ Returns: (operation, summary, collection_name)
48
+ """
49
+ sql_upper = sql.upper().strip()
50
+ operation = sql_upper.split()[0] if sql_upper else "UNKNOWN"
51
+
52
+ # Pattern to match quoted and unquoted identifiers
53
+ # Matches: "quoted", `quoted`, [quoted], unquoted.name
54
+ identifier_pattern = r'("([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([\w.]+))'
55
+
56
+ # Extract table/collection name based on operation
57
+ collection_name = None
58
+ summary = operation
59
+
60
+ if operation in ("SELECT", "DELETE"):
61
+ match = re.search(rf"FROM\s+{identifier_pattern}", sql, re.IGNORECASE)
62
+ if match:
63
+ collection_name = _clean_identifier(match.group(1))
64
+ summary = f"{operation} {collection_name}"
65
+
66
+ elif operation in ("INSERT", "REPLACE"):
67
+ match = re.search(rf"INTO\s+{identifier_pattern}", sql, re.IGNORECASE)
68
+ if match:
69
+ collection_name = _clean_identifier(match.group(1))
70
+ summary = f"{operation} {collection_name}"
71
+
72
+ elif operation == "UPDATE":
73
+ match = re.search(rf"UPDATE\s+{identifier_pattern}", sql, re.IGNORECASE)
74
+ if match:
75
+ collection_name = _clean_identifier(match.group(1))
76
+ summary = f"{operation} {collection_name}"
77
+
78
+ return operation, summary, collection_name
79
+
80
+
81
+ def _clean_identifier(identifier: str) -> str:
82
+ """Remove quotes from SQL identifiers."""
83
+ # Remove different types of SQL quotes
84
+ if identifier.startswith('"') and identifier.endswith('"'):
85
+ return identifier[1:-1]
86
+ elif identifier.startswith("`") and identifier.endswith("`"):
87
+ return identifier[1:-1]
88
+ elif identifier.startswith("[") and identifier.endswith("]"):
89
+ return identifier[1:-1]
90
+ return identifier
91
+
92
+
93
+ @contextmanager
94
+ def db_span(db, sql: Any, *, many: bool = False, params=None):
95
+ """Open an OpenTelemetry CLIENT span for a database query.
96
+
97
+ All common attributes (`db.*`, `network.*`, etc.) are set automatically.
98
+ Follows OpenTelemetry semantic conventions for database instrumentation.
99
+ """
100
+
101
+ # Fast-exit if instrumentation suppression flag set in context.
102
+ if otel_context.get_value(_SUPPRESS_KEY):
103
+ yield None
104
+ return
105
+
106
+ sql = str(sql) # Ensure SQL is a string for span attributes.
107
+
108
+ # Extract operation and target information
109
+ operation, summary, collection_name = extract_operation_and_target(sql)
110
+
111
+ if many:
112
+ summary = f"{summary} many"
113
+
114
+ # Span name follows semantic conventions: {target} or {db.operation.name} {target}
115
+ if summary:
116
+ span_name = summary[:255]
117
+ else:
118
+ span_name = operation
119
+
120
+ # Build attribute set following semantic conventions
121
+ attrs: dict[str, Any] = {
122
+ DB_SYSTEM_NAME: db_system_for(db.vendor),
123
+ DB_NAMESPACE: db.settings_dict.get("NAME"),
124
+ DB_QUERY_TEXT: sql, # Already parameterized from Django/Plain
125
+ DB_QUERY_SUMMARY: summary,
126
+ DB_OPERATION_NAME: operation,
127
+ }
128
+
129
+ # Add collection name if detected
130
+ if collection_name:
131
+ attrs[DB_COLLECTION_NAME] = collection_name
132
+
133
+ # Add user attribute
134
+ if user := db.settings_dict.get("USER"):
135
+ attrs[DB_USER] = user
136
+
137
+ # Network attributes
138
+ if host := db.settings_dict.get("HOST"):
139
+ attrs[NETWORK_PEER_ADDRESS] = host
140
+
141
+ if port := db.settings_dict.get("PORT"):
142
+ try:
143
+ attrs[NETWORK_PEER_PORT] = int(port)
144
+ except (TypeError, ValueError):
145
+ pass
146
+
147
+ # Add query parameters as attributes when DEBUG is True
148
+ if settings.DEBUG and params is not None:
149
+ # Convert params to appropriate format based on type
150
+ if isinstance(params, dict):
151
+ # Dictionary params (e.g., for named placeholders)
152
+ for i, (key, value) in enumerate(params.items()):
153
+ attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.{key}"] = str(value)
154
+ elif isinstance(params, list | tuple):
155
+ # Sequential params (e.g., for %s or ? placeholders)
156
+ for i, value in enumerate(params):
157
+ attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.{i + 1}"] = str(value)
158
+ else:
159
+ # Single param (rare but possible)
160
+ attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.1"] = str(params)
161
+
162
+ with tracer.start_as_current_span(
163
+ span_name, kind=SpanKind.CLIENT, attributes=attrs
164
+ ) as span:
165
+ yield span
166
+ span.set_status(trace.StatusCode.OK)
167
+
168
+
169
+ @contextmanager
170
+ def suppress_db_tracing():
171
+ token = otel_context.attach(otel_context.set_value(_SUPPRESS_KEY, True))
172
+ try:
173
+ yield
174
+ finally:
175
+ otel_context.detach(token)
@@ -2,6 +2,7 @@ import re
2
2
 
3
3
  import pytest
4
4
 
5
+ from plain.models.otel import suppress_db_tracing
5
6
  from plain.signals import request_finished, request_started
6
7
 
7
8
  from .. import transaction
@@ -60,29 +61,32 @@ def setup_db(request):
60
61
  def db(setup_db, request):
61
62
  if "isolated_db" in request.fixturenames:
62
63
  pytest.fail("The 'db' and 'isolated_db' fixtures cannot be used together")
64
+
63
65
  # Set .cursor() back to the original implementation to unblock it
64
66
  BaseDatabaseWrapper.cursor = BaseDatabaseWrapper._enabled_cursor
65
67
 
66
68
  if not db_connection.features.supports_transactions:
67
69
  pytest.fail("Database does not support transactions")
68
70
 
69
- atomic = transaction.atomic()
70
- atomic._from_testcase = True # TODO remove this somehow?
71
- atomic.__enter__()
71
+ with suppress_db_tracing():
72
+ atomic = transaction.atomic()
73
+ atomic._from_testcase = True # TODO remove this somehow?
74
+ atomic.__enter__()
72
75
 
73
76
  yield
74
77
 
75
- if (
76
- db_connection.features.can_defer_constraint_checks
77
- and not db_connection.needs_rollback
78
- and db_connection.is_usable()
79
- ):
80
- db_connection.check_constraints()
78
+ with suppress_db_tracing():
79
+ if (
80
+ db_connection.features.can_defer_constraint_checks
81
+ and not db_connection.needs_rollback
82
+ and db_connection.is_usable()
83
+ ):
84
+ db_connection.check_constraints()
81
85
 
82
- db_connection.set_rollback(True)
83
- atomic.__exit__(None, None, None)
86
+ db_connection.set_rollback(True)
87
+ atomic.__exit__(None, None, None)
84
88
 
85
- db_connection.close()
89
+ db_connection.close()
86
90
 
87
91
 
88
92
  @pytest.fixture
@@ -0,0 +1,14 @@
1
+ from plain.models import db_connection
2
+ from plain.models.otel import suppress_db_tracing
3
+
4
+
5
+ def setup_database(*, verbosity, prefix=""):
6
+ old_name = db_connection.settings_dict["NAME"]
7
+ with suppress_db_tracing():
8
+ db_connection.creation.create_test_db(verbosity=verbosity, prefix=prefix)
9
+ return old_name
10
+
11
+
12
+ def teardown_database(old_name, verbosity):
13
+ with suppress_db_tracing():
14
+ db_connection.creation.destroy_test_db(old_name, verbosity)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.models"
3
- version = "0.35.0"
3
+ version = "0.37.0"
4
4
  description = "Database models for Plain."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  readme = "README.md"
@@ -1,11 +0,0 @@
1
- from plain.models import db_connection
2
-
3
-
4
- def setup_database(*, verbosity, prefix=""):
5
- old_name = db_connection.settings_dict["NAME"]
6
- db_connection.creation.create_test_db(verbosity=verbosity, prefix=prefix)
7
- return old_name
8
-
9
-
10
- def teardown_database(old_name, verbosity):
11
- db_connection.creation.destroy_test_db(old_name, verbosity)
File without changes
File without changes