spark-sql-migrations 0.0.6__tar.gz → 0.0.8__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 (22) hide show
  1. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/PKG-INFO +1 -1
  2. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/setup.py +1 -1
  3. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations/spark_sql/spark_sql.py +103 -43
  4. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations/spark_utils.py +3 -3
  5. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations.egg-info/PKG-INFO +1 -1
  6. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/LICENSE +0 -0
  7. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/MANIFEST.in +0 -0
  8. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/backend_shim.py +0 -0
  9. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/setup.cfg +0 -0
  10. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations/__init__.py +0 -0
  11. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations/migration_templates/default_migration_template.sql +0 -0
  12. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations/migrations_initial/first_01_intial_create_cat.sql +0 -0
  13. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations/migrations_initial/first_02_intial_create_schema.sql +0 -0
  14. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations/migrations_initial/first_03_intial_create_state_table.sql +0 -0
  15. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations/py.typed +0 -0
  16. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations/spark_sql/__init__.py +0 -0
  17. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations.egg-info/SOURCES.txt +0 -0
  18. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations.egg-info/dependency_links.txt +0 -0
  19. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations.egg-info/entry_points.txt +0 -0
  20. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations.egg-info/namespace_packages.txt +0 -0
  21. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations.egg-info/requires.txt +0 -0
  22. {spark_sql_migrations-0.0.6 → spark_sql_migrations-0.0.8}/spark_sql_migrations.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spark_sql_migrations
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: Chained, idempotent SQL migrations for Spark and Databricks.
5
5
  Home-page: https://github.com/mcjug2015/spark_sql_migrations
6
6
  Author: Victor Semenov
@@ -299,5 +299,5 @@ Apache License 2.0 — see [LICENSE](LICENSE).
299
299
  },
300
300
  'python_requires': '>=3.10',
301
301
  'url': 'https://github.com/mcjug2015/spark_sql_migrations',
302
- 'version': '0.0.6',
302
+ 'version': '0.0.8',
303
303
  })
@@ -27,8 +27,11 @@ import os
27
27
  import re
28
28
  import uuid
29
29
  from dataclasses import dataclass
30
+ from typing import Any, Dict, List, Optional
30
31
 
31
32
  from jinja2 import Environment, FileSystemLoader, PackageLoader, select_autoescape
33
+ from pyspark.sql.session import SparkSession
34
+ from pyspark.sql.types import Row
32
35
 
33
36
  from spark_sql_migrations.spark_utils import get_spark, is_dbr
34
37
 
@@ -51,17 +54,17 @@ _COMMENT_OR_BLANK_RE = re.compile(r"^\s*(--.*)?$")
51
54
  @dataclass(frozen=True)
52
55
  class Migration:
53
56
  revision_id: str
54
- prev_revision_id: str | None
57
+ prev_revision_id: Optional[str]
55
58
  template_name: str
56
59
 
57
60
 
58
- def get_default_template_path():
61
+ def get_default_template_path() -> str:
59
62
  """the template this package ships; callers may pass their own instead."""
60
63
  return os.path.join(os.path.dirname(__file__), "..", TEMPLATES_PACKAGE_PATH, DEFAULT_TEMPLATE_NAME)
61
64
 
62
65
 
63
- def _parse_migration(migrations_dir, template_name):
64
- headers = {}
66
+ def _parse_migration(migrations_dir: str, template_name: str) -> Migration:
67
+ headers: Dict[str, Any | str] = {}
65
68
  migration_file_path = os.path.join(migrations_dir, template_name)
66
69
  if os.path.getsize(migration_file_path) == 0:
67
70
  raise ValueError(f"{template_name} is empty;")
@@ -84,8 +87,8 @@ def _parse_migration(migrations_dir, template_name):
84
87
  )
85
88
 
86
89
 
87
- def get_ordered_migration_objs(migration_objs):
88
- by_revision = {}
90
+ def get_ordered_migration_objs(migration_objs: List[Migration]) -> List[Migration]:
91
+ by_revision: Dict[str, Migration] = {}
89
92
  for migration in migration_objs:
90
93
  clash = by_revision.get(migration.revision_id)
91
94
  if clash:
@@ -102,7 +105,7 @@ def get_ordered_migration_objs(migration_objs):
102
105
  f"found {[m.template_name for m in roots]};"
103
106
  )
104
107
 
105
- next_by_revision = {}
108
+ next_by_revision: Dict[str, Migration] = {}
106
109
  for migration in migration_objs:
107
110
  if not migration.prev_revision_id:
108
111
  continue
@@ -119,7 +122,7 @@ def get_ordered_migration_objs(migration_objs):
119
122
  next_by_revision[migration.prev_revision_id] = migration
120
123
 
121
124
  ordered = []
122
- current = roots[0]
125
+ current: Optional[Migration] = roots[0]
123
126
  while current:
124
127
  ordered.append(current)
125
128
  current = next_by_revision.get(current.revision_id)
@@ -134,8 +137,14 @@ def get_ordered_migration_objs(migration_objs):
134
137
  return ordered
135
138
 
136
139
 
137
- def get_migrations_list(migrations_dir):
138
- """every migration in one directory, walked from its root to its head."""
140
+ def get_migrations_list(migrations_dir: str, max_revision_id: Optional[str] = None) -> List[Migration]:
141
+ """every migration in one directory, walked from its root to its head.
142
+
143
+ ``revision_id`` cuts the walk short: the chain is returned up to and including
144
+ the migration carrying that revision. A revision no migration in the directory
145
+ carries is not an error -- the whole chain comes back, same as for no revision
146
+ at all.
147
+ """
139
148
  if not os.path.isdir(migrations_dir):
140
149
  raise ValueError(f"no migrations dir at {migrations_dir};")
141
150
 
@@ -147,10 +156,18 @@ def get_migrations_list(migrations_dir):
147
156
  if not migrations:
148
157
  return []
149
158
 
150
- return get_ordered_migration_objs(migrations)
159
+ ordered = get_ordered_migration_objs(migrations)
160
+ if max_revision_id is None:
161
+ return ordered
162
+
163
+ for index, migration in enumerate(ordered):
164
+ if migration.revision_id == max_revision_id:
165
+ return ordered[: index + 1]
166
+
167
+ return ordered
151
168
 
152
169
 
153
- def get_table_version(spark, cat: str, schema: str):
170
+ def get_table_version(spark: SparkSession, cat: str, schema: str) -> Optional[Row]:
154
171
  """the revision VERSION_TABLE records, or None if it holds no row yet."""
155
172
  version_table = f"{cat}.{schema}.{VERSION_TABLE}"
156
173
  if not spark.catalog.tableExists(version_table):
@@ -158,8 +175,10 @@ def get_table_version(spark, cat: str, schema: str):
158
175
  return spark.read.table(version_table).select("version_num").head()
159
176
 
160
177
 
161
- def get_unapplied_migrations_list(spark, migrations_dir, cat: str, schema: str):
162
- ordered = get_migrations_list(migrations_dir)
178
+ def get_unapplied_migrations_list(
179
+ spark: SparkSession, migrations_dir: str, cat: str, schema: str, max_revision_id: Optional[str] = None
180
+ ) -> List[Migration]:
181
+ ordered = get_migrations_list(migrations_dir, max_revision_id)
163
182
  current = get_table_version(spark, cat, schema)
164
183
  if not current:
165
184
  return ordered
@@ -180,7 +199,7 @@ def get_unapplied_migrations_list(spark, migrations_dir, cat: str, schema: str):
180
199
  raise ValueError(f"{VERSION_TABLE} is at {current_revision_id}, which matches no migration in {migrations_dir};")
181
200
 
182
201
 
183
- def gate_on_is_dbr(migration_content):
202
+ def gate_on_is_dbr(migration_content: str) -> str:
184
203
  """wrap a migration's body in the jinja conditional, below its headers.
185
204
 
186
205
  the revision headers stay outside it: they are read off the file as comments,
@@ -199,7 +218,7 @@ def gate_on_is_dbr(migration_content):
199
218
 
200
219
  def create_new_migration(
201
220
  message: str, template_path: str, output_path: str, truncate_slug_length: int = 40, add_is_dbr: bool = False
202
- ):
221
+ ) -> None:
203
222
  rev_id = uuid.uuid4().hex[-12:]
204
223
 
205
224
  date_prefix = datetime.datetime.today().strftime("%y%m%d")
@@ -228,32 +247,38 @@ def create_new_migration(
228
247
  migration_file.write(default_migration_content)
229
248
 
230
249
 
231
- def apply_template(output_dir, template, cat: str, schema: str):
250
+ def apply_template(output_dir: str, template, cat: str, schema: str) -> str:
232
251
  result_sql = template.render(cat=cat, schema=schema, is_dbr=is_dbr())
233
252
  with open(os.path.join(output_dir, template.name.replace(".sql", "_primed.sql")), "w") as file_handle:
234
253
  file_handle.write(result_sql)
235
254
  return result_sql
236
255
 
237
256
 
238
- def get_ascending_letters_within_minute():
239
- micros_since_minute = datetime.datetime.now() - datetime.datetime.now().replace(second=0, microsecond=0)
240
- result = str(micros_since_minute.microseconds).translate(str.maketrans("0123456789", "ABCDEFGHIJ"))
241
- return result
257
+ def get_ascending_letters_within_minute() -> str:
258
+ """a letter stamp that sorts in the order it was taken, within one minute.
259
+
260
+ the full microseconds since the minute began, not timedelta.microseconds -- that
261
+ holds only the sub-second part and so starts over every second. zero-padded to a
262
+ fixed width, because these are compared as text once they are folder names.
263
+ """
264
+ now = datetime.datetime.now()
265
+ micros_since_minute = (now - now.replace(second=0, microsecond=0)) // datetime.timedelta(microseconds=1)
266
+ return f"{micros_since_minute:08d}".translate(str.maketrans("0123456789", "ABCDEFGHIJ"))
242
267
 
243
268
 
244
- def get_output_folder(output_parent_path):
269
+ def get_output_folder(output_parent_path: str) -> str:
245
270
  folder_name = f"{datetime.datetime.today().strftime('%Y%m%d_%H%M')}_{get_ascending_letters_within_minute()}"
246
271
  return os.path.join(output_parent_path, folder_name)
247
272
 
248
273
 
249
- def has_no_statements(rendered_sql):
274
+ def has_no_statements(rendered_sql: str) -> bool:
250
275
  for line in rendered_sql.splitlines():
251
276
  if not _COMMENT_OR_BLANK_RE.match(line):
252
277
  return False
253
278
  return True
254
279
 
255
280
 
256
- def render_and_apply(spark, output_folder, template, cat: str, schema: str):
281
+ def render_and_apply(spark: SparkSession, output_folder: str, template, cat: str, schema: str) -> None:
257
282
  """render one migration and run it, unless this engine was gated out of it."""
258
283
  result_sql = apply_template(output_folder, template, cat=cat, schema=schema)
259
284
  if has_no_statements(result_sql):
@@ -262,7 +287,7 @@ def render_and_apply(spark, output_folder, template, cat: str, schema: str):
262
287
  spark.sql(result_sql)
263
288
 
264
289
 
265
- def migrate_initial(spark, output_folder, cat: str, schema: str):
290
+ def migrate_initial(spark: SparkSession, output_folder: str, cat: str, schema: str) -> None:
266
291
  """apply the package's own bootstrap chain, in filename order rather than by revision."""
267
292
  env = Environment(
268
293
  loader=PackageLoader(package_name=PACKAGE_NAME, package_path=INITIAL_MIGRATIONS_PACKAGE_PATH),
@@ -276,14 +301,21 @@ def migrate_initial(spark, output_folder, cat: str, schema: str):
276
301
  logger.info(f"rendered {len(all_templates)} initial migrations;")
277
302
 
278
303
 
279
- def record_revision(spark, cat: str, schema: str, revision_id: str):
304
+ def record_revision(spark: SparkSession, cat: str, schema: str, revision_id: str) -> None:
280
305
  version_table = f"{cat}.{schema}.{VERSION_TABLE}"
281
306
  revision_literal = revision_id.replace("'", "''")
282
307
  spark.sql(f"delete from {version_table}")
283
308
  spark.sql(f"insert into {version_table} (version_num) values ('{revision_literal}')")
284
309
 
285
310
 
286
- def migrate_w_rev(spark, output_folder, migrations_dir, cat: str, schema: str):
311
+ def upgrade(
312
+ spark: SparkSession,
313
+ output_folder: str,
314
+ migrations_dir: str,
315
+ cat: str,
316
+ schema: str,
317
+ max_revision_id: Optional[str] = None,
318
+ ) -> None:
287
319
  """
288
320
  TODO XXX permit applying up to a revision below the head revision
289
321
  TODO XXX add backwards migrations, permit migrating backwards
@@ -291,7 +323,9 @@ def migrate_w_rev(spark, output_folder, migrations_dir, cat: str, schema: str):
291
323
  if not os.path.isdir(migrations_dir):
292
324
  logger.info(f"no chain at {migrations_dir}; skipping it;")
293
325
  return
294
- unapplied = get_unapplied_migrations_list(spark, migrations_dir, cat=cat, schema=schema)
326
+ unapplied = get_unapplied_migrations_list(
327
+ spark, migrations_dir, cat=cat, schema=schema, max_revision_id=max_revision_id
328
+ )
295
329
 
296
330
  logger.info(
297
331
  f"{len(unapplied)} unapplied migrations; "
@@ -305,7 +339,14 @@ def migrate_w_rev(spark, output_folder, migrations_dir, cat: str, schema: str):
305
339
  logger.info(f"{migration.template_name} done; now at {migration.revision_id};")
306
340
 
307
341
 
308
- def run_migrations(spark, cat, schema, output_folder, migrations_dir):
342
+ def run_upgrade(
343
+ spark: SparkSession,
344
+ cat: str,
345
+ schema: str,
346
+ output_folder: str,
347
+ migrations_dir: str,
348
+ max_revision_id: Optional[str] = None,
349
+ ) -> None:
309
350
  initial_output_folder = os.path.join(output_folder, INITIAL_MIGRATIONS_PACKAGE_PATH)
310
351
  client_output_folder = os.path.join(output_folder, CLIENT_OUTPUT_DIRNAME)
311
352
  os.makedirs(initial_output_folder, exist_ok=True)
@@ -315,42 +356,61 @@ def run_migrations(spark, cat, schema, output_folder, migrations_dir):
315
356
  logger.info(f"{version_table} already exists; skipping the initial migrations;")
316
357
  else:
317
358
  migrate_initial(spark, initial_output_folder, cat, schema)
318
- migrate_w_rev(spark, client_output_folder, migrations_dir, cat, schema)
359
+ upgrade(spark, client_output_folder, migrations_dir, cat, schema, max_revision_id)
319
360
 
320
361
 
321
- def main(cat, schema, migrations_dir, output_parent_path=None):
362
+ def main(
363
+ cat: str,
364
+ schema: str,
365
+ migrations_dir: str,
366
+ output_parent_path: Optional[str] = None,
367
+ max_revision_id: Optional[str] = None,
368
+ ) -> None:
322
369
  output_folder = get_output_folder(output_parent_path or os.path.join(os.getcwd(), "migrations_out"))
323
370
  os.makedirs(output_folder)
324
- run_migrations(get_spark(), cat, schema, output_folder, migrations_dir)
371
+ run_upgrade(get_spark(), cat, schema, output_folder, migrations_dir, max_revision_id)
325
372
 
326
373
 
327
- def _cli_main(cat, schema, migrations_dir): # pragma: no cover
328
- main(cat, schema, migrations_dir)
374
+ def _cli_main(
375
+ cat: str, schema: str, migrations_dir: str, max_revision_id: Optional[str] = None
376
+ ) -> None: # pragma: no cover
377
+ main(cat, schema, migrations_dir, max_revision_id=max_revision_id)
329
378
 
330
379
 
331
- def _cli_create_new_migration(message, output_path, template_path, add_is_dbr): # pragma: no cover
380
+ def _cli_create_new_migration(
381
+ message: str, output_path: str, template_path: str, add_is_dbr: bool
382
+ ) -> None: # pragma: no cover
332
383
  create_new_migration(message, template_path or get_default_template_path(), output_path, add_is_dbr=add_is_dbr)
333
384
 
334
385
 
335
- def build_parser(): # pragma: no cover
386
+ def build_parser() -> argparse.ArgumentParser: # pragma: no cover
336
387
  parser = argparse.ArgumentParser(description="spark_sql_migrations migration utilities")
337
388
  subparsers = parser.add_subparsers(dest="command", required=True)
338
389
 
339
- p_run = subparsers.add_parser("run", help="apply all pending migrations")
340
- p_run.add_argument("--cat", default="spark_catalog")
341
- p_run.add_argument("--schema", default="default")
342
- p_run.add_argument("--migrations-dir", required=True, help="the directory holding the migration chain")
390
+ p_run = subparsers.add_parser("run", help="upgrade to head/apply pending migrations")
391
+ p_run.add_argument("--cat", type=str, default="spark_catalog")
392
+ p_run.add_argument("--schema", type=str, default="default")
393
+ p_run.add_argument("--migrations-dir", type=str, required=True, help="the directory holding the migration chain")
394
+ p_run.add_argument(
395
+ "--max-revision-id",
396
+ type=str,
397
+ default=None,
398
+ help="apply the migration carrying this revision_id and stop; defaults to applying the chain to its head",
399
+ )
343
400
  p_run.set_defaults(func=_cli_main)
344
401
 
345
402
  p_create = subparsers.add_parser("create_new_migration", help="create a new migration from the template")
346
- p_create.add_argument("--message", required=True)
403
+ p_create.add_argument("--message", type=str, required=True)
347
404
  p_create.add_argument(
348
405
  "--output-path",
406
+ type=str,
349
407
  required=True,
350
408
  help="the chain directory to write the new migration into. if sql files are in there, the last sql's rev_id"
351
409
  "will be set as the new migrations prev_rev_id.",
352
410
  )
353
- p_create.add_argument("--template-path", default=None, help="defaults to the template shipped in this package")
411
+ p_create.add_argument(
412
+ "--template-path", type=str, default=None, help="defaults to the template shipped in this package"
413
+ )
354
414
  # the %% are argparse's: it interpolates help strings
355
415
  p_create.add_argument(
356
416
  "--add-is-dbr",
@@ -362,7 +422,7 @@ def build_parser(): # pragma: no cover
362
422
  return parser
363
423
 
364
424
 
365
- def cli(argv=None): # pragma: no cover
425
+ def cli(argv: Optional[list[str]] = None) -> None: # pragma: no cover
366
426
  """the `spark-sql-migrations` console command.
367
427
 
368
428
  an application entry point, so unlike the rest of this package it configures
@@ -11,7 +11,7 @@ METASTORE_DIR_ENV_VAR = "SPARK_METASTORE_DIR"
11
11
  DEFAULT_WAREHOUSE_DIRNAME = "spark-warehouse"
12
12
 
13
13
 
14
- def is_dbr():
14
+ def is_dbr() -> bool:
15
15
  try:
16
16
  from pyspark.dbutils import DBUtils # type: ignore # noqa: F401
17
17
 
@@ -21,7 +21,7 @@ def is_dbr():
21
21
  return False
22
22
 
23
23
 
24
- def get_warehouse_dir():
24
+ def get_warehouse_dir() -> str:
25
25
  """where local (non-DBR) spark keeps its warehouse.
26
26
 
27
27
  resolved against the caller's cwd, never against __file__: installed from a
@@ -34,7 +34,7 @@ def get_warehouse_dir():
34
34
  )
35
35
 
36
36
 
37
- def get_spark(use_dbc=False):
37
+ def get_spark(use_dbc: bool = False) -> SparkSession:
38
38
  if use_dbc or is_dbr():
39
39
  from databricks.connect.session import DatabricksSession # type: ignore # pants: no-infer-dep
40
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spark_sql_migrations
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: Chained, idempotent SQL migrations for Spark and Databricks.
5
5
  Home-page: https://github.com/mcjug2015/spark_sql_migrations
6
6
  Author: Victor Semenov