#!/usr/bin/env python3 # # Copyright (C) CVAT.ai Corporation # # SPDX-License-Identifier: MIT """ Dump the test CVAT database to JSON with stable ordering. The default ``manage.py dumpdata`` output is not deterministic: record order within a model depends on the underlying ``SELECT`` order, which can differ between runs even when DB contents are identical. This causes spurious diffs in ``tests/python/shared/assets/cvat_db/data.json`` after each dump, which then need to be reviewed and reverted by hand. This script wraps ``dumpdata`` and post-processes its output to make the result stable: * records are sorted alphabetically by ``(model, pk)`` (the ``loaddata_sorted`` management command reorders by FK dependency at load time, so the on-disk order can be arbitrary); * field keys within each record are sorted alphabetically; * known unordered many-to-many fields (e.g. ``auth.user.groups``) are sorted as well. It can also preserve volatile timestamp fields (``last_login``, ``*_updated_date``, ``last_used_date`` and similar) from the existing output file. When preservation is enabled (the default), an unchanged record keeps its previous timestamp values, so re-dumping after a no-op test run produces zero diff. Genuinely new records get whatever timestamp the server wrote. Usage: # default: run dumpdata in the test container and write the # normalized output to tests/python/shared/assets/cvat_db/data.json, # preserving volatile fields from the existing file. python tests/python/shared/utils/dump_test_db.py # read pre-existing dump from a file (or stdin via "-") instead of # running docker exec python tests/python/shared/utils/dump_test_db.py --from-file dump.json # also refresh volatile fields (don't preserve from existing output) python tests/python/shared/utils/dump_test_db.py --refresh-volatile # write to a custom location / stdout python tests/python/shared/utils/dump_test_db.py -o /tmp/data.json python tests/python/shared/utils/dump_test_db.py -o - # extend the built-in volatile/sortable maps and dumpdata excludes # from a JSON file (e.g. when running against a Django project that # adds models beyond the OSS apps covered by this script or you want # to customize the dump). python tests/python/shared/utils/dump_test_db.py \\ --extra-config path/to/dump_extra_config.json The extra config file has up to three optional sections: { "extra_volatile_fields": { "": ["", ...], ... }, "extra_sortable_list_fields": { "": ["", ...], ... }, "extra_excludes": ["", ...] } Field maps union with the built-in entries per model; ``extra_excludes`` append to ``DUMPDATA_EXCLUDES`` (and are ignored when ``--from-file`` is used, since the dump is already produced). """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from subprocess import PIPE, run from typing import Any DEFAULT_CONTAINER_NAME = "test_cvat_server_1" DEFAULT_OUTPUT = Path(__file__).resolve().parents[2] / "shared" / "assets" / "cvat_db" / "data.json" STDOUT_SENTINEL = "-" DUMPDATA_EXCLUDES = ( "admin", "auth.permission", "authtoken", "contenttypes", "django_rq", "sessions", ) # Fields whose value changes on every dump even when no test-relevant state # has changed (auto_now timestamps, last-login markers, etc.). When # preservation is enabled the script copies these values from the existing # data.json so that re-dumping an unchanged DB produces no diff. VOLATILE_FIELDS: dict[str, frozenset[str]] = { "auth.user": frozenset({"last_login"}), "engine.profile": frozenset({"last_activity_date"}), "quality_control.qualityreport": frozenset( {"assignee_last_updated", "gt_last_updated", "target_last_updated"} ), "access_tokens.accesstoken": frozenset({"last_used_date"}), } # Any field whose name is exactly ``updated_date`` or ends with # ``_updated_date`` is treated as volatile for every model. ``auto_now`` # timestamps drift on every save and the exact value is not relevant for # tests, so we don't track the per-model list of such fields explicitly. def _is_blanket_volatile(field_name: str) -> bool: return field_name == "updated_date" or field_name.endswith("_updated_date") # Fields holding many-to-many references whose element order is not # semantically meaningful. Sorting them stabilizes the dump even if the # m2m join table returns rows in a different order between runs. SORTABLE_LIST_FIELDS: dict[str, frozenset[str]] = { "auth.user": frozenset({"groups", "user_permissions"}), "auth.group": frozenset({"permissions"}), } # ``DjangoJSONEncoder`` truncates microseconds to milliseconds: a value # with ``microsecond`` in [1, 999] is encoded as ``.000Z``. After # ``loaddata`` parses that back to ``microsecond=0``, the next ``dumpdata`` # omits the fraction entirely and writes a bare ``Z`` instead, producing a # spurious diff. Drop the redundant zero milliseconds up-front so the # round-trip is idempotent. ZERO_MS_PATTERN = r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.000(Z|[+-]\d{2}:\d{2})$" _DEFAULT = object() def _strip_zero_milliseconds(value: Any) -> Any: if not isinstance(value, str): return value return re.sub(ZERO_MS_PATTERN, r"\1\2", value) def _pk_sort_key(pk: Any) -> tuple: # `pk` is usually an int but can be a list of natural-key parts. # Wrap into a tuple of (type_tag, value) so heterogeneous pks order # consistently without TypeError. if isinstance(pk, (int, float)): return (0, pk) if isinstance(pk, list): return (1, tuple(_pk_sort_key(p) for p in pk)) return (2, str(pk)) def _normalize_record( record: dict[str, Any], *, sortable_list_fields: dict[str, frozenset[str]] | Any = _DEFAULT, ) -> dict[str, Any]: if sortable_list_fields is _DEFAULT: sortable_list_fields = SORTABLE_LIST_FIELDS model = record.get("model", "") fields = record.get("fields", {}) sortable = sortable_list_fields.get(model, frozenset()) new_fields: dict[str, Any] = {} for key in sorted(fields): value = fields[key] if key in sortable and isinstance(value, list): try: value = sorted(value, key=lambda v: json.dumps(v, sort_keys=True)) except TypeError: pass value = _strip_zero_milliseconds(value) new_fields[key] = value return {"model": model, "pk": record.get("pk"), "fields": new_fields} def _preserve_volatile( records: list[dict[str, Any]], reference: list[dict[str, Any]], *, volatile_fields: dict[str, frozenset[str]] | Any = _DEFAULT, ) -> list[dict[str, Any]]: if volatile_fields is _DEFAULT: volatile_fields = VOLATILE_FIELDS ref_index = { (r.get("model", ""), _pk_sort_key(r.get("pk"))): r.get("fields", {}) for r in reference } for record in records: model = record.get("model", "") ref_fields = ref_index.get((model, _pk_sort_key(record.get("pk")))) if ref_fields is None: continue explicit = volatile_fields.get(model, frozenset()) fields = record.get("fields", {}) for name in fields: if name not in ref_fields: continue if name in explicit or _is_blanket_volatile(name): fields[name] = ref_fields[name] return records def normalize( records: list[dict[str, Any]], *, reference: list[dict[str, Any]] | None = None, volatile_fields: dict[str, frozenset[str]] | Any = _DEFAULT, sortable_list_fields: dict[str, frozenset[str]] | Any = _DEFAULT, ) -> list[dict[str, Any]]: """Return ``records`` sorted and field-normalized, with volatile fields optionally preserved from ``reference``.""" if volatile_fields is _DEFAULT: volatile_fields = VOLATILE_FIELDS if sortable_list_fields is _DEFAULT: sortable_list_fields = _DEFAULT normalized = [_normalize_record(r, sortable_list_fields=sortable_list_fields) for r in records] if reference is not None: _preserve_volatile(normalized, reference=reference, volatile_fields=volatile_fields) # Sort alphabetically by model name (then by pk) so the committed file # has a layout that doesn't depend on Django's dependency sort. The # ``loaddata_sorted`` management command reorders records by dependency # at load time, so any total ordering is fine here. normalized.sort(key=lambda r: (r.get("model", ""), _pk_sort_key(r.get("pk")))) return normalized def dump_from_container( container: str = DEFAULT_CONTAINER_NAME, *, excludes: tuple[str, ...] | list[str] = DUMPDATA_EXCLUDES, ) -> list[dict[str, Any]]: cmd = [ "docker", "exec", container, "python", "manage.py", "dumpdata", "--indent", "2", "--natural-foreign", ] for ex in excludes: cmd.extend(["--exclude", ex]) proc = run(cmd, check=True, stdout=PIPE) # nosec return json.loads(proc.stdout) EXTRA_CONFIG_KEYS = ("extra_volatile_fields", "extra_sortable_list_fields", "extra_excludes") # Conventional Django ``app_label.modelname`` shape: lowercase identifier on # each side, joined by a single dot. Mismatch is a warning (not an error) # because the script doesn't load Django and can't authoritatively validate # model names — but the convention catches typos like ``engine.Task``. _MODEL_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$") class _ConfigError(ValueError): """Raised when ``--extra-config`` content is malformed.""" def _require_non_empty_str(value: Any, where: str) -> None: if not isinstance(value, str): raise _ConfigError(f"{where}: expected string, got {type(value).__name__}") if not value: raise _ConfigError(f"{where}: expected non-empty string") def _validate_field_map(section: Any, where: str) -> None: if not isinstance(section, dict): raise _ConfigError(f"{where}: expected object, got {type(section).__name__}") for model, fields in section.items(): _require_non_empty_str(model, f"{where} key") if not _MODEL_NAME_RE.match(model): print( f"warning: {where}.{model!r} does not match the conventional " ". shape", file=sys.stderr, ) if not isinstance(fields, list): raise _ConfigError(f"{where}.{model}: expected list, got {type(fields).__name__}") seen: set[str] = set() for i, field in enumerate(fields): _require_non_empty_str(field, f"{where}.{model}[{i}]") if field in seen: raise _ConfigError(f"{where}.{model}[{i}]: duplicate field {field!r}") seen.add(field) def _validate_excludes(section: Any, where: str) -> None: if not isinstance(section, list): raise _ConfigError(f"{where}: expected list, got {type(section).__name__}") seen: set[str] = set() for i, item in enumerate(section): _require_non_empty_str(item, f"{where}[{i}]") if item in seen: raise _ConfigError(f"{where}[{i}]: duplicate exclude {item!r}") seen.add(item) def _load_extra_config(path: Path) -> dict[str, Any]: """Read and validate an extension config file. See ``EXTRA_CONFIG_KEYS`` for the allowed top-level sections; missing sections default to empty. Malformed content raises ``_ConfigError`` with a JSON-pointer-style path so the offending entry is easy to find. """ raw = json.loads(path.read_text()) if not isinstance(raw, dict): raise _ConfigError(f"{path}: top-level must be an object, got {type(raw).__name__}") unknown = sorted(set(raw) - set(EXTRA_CONFIG_KEYS)) if unknown: raise _ConfigError( f"{path}: unknown top-level keys: {unknown}. " f"valid keys: {list(EXTRA_CONFIG_KEYS)}" ) extra_volatile = raw.get("extra_volatile_fields", {}) extra_sortable = raw.get("extra_sortable_list_fields", {}) extra_excludes = raw.get("extra_excludes", []) _validate_field_map(extra_volatile, "extra_volatile_fields") _validate_field_map(extra_sortable, "extra_sortable_list_fields") _validate_excludes(extra_excludes, "extra_excludes") return { "extra_volatile_fields": extra_volatile, "extra_sortable_list_fields": extra_sortable, "extra_excludes": extra_excludes, } def _merge_field_map( base: dict[str, frozenset[str]], extra: dict[str, list[str]], ) -> dict[str, frozenset[str]]: merged = dict(base) for model, fields in extra.items(): merged[model] = merged.get(model, frozenset()) | frozenset(fields) return merged def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( "-o", "--output", type=str, default=str(DEFAULT_OUTPUT), help=f'Output file (default: %(default)s). Use "{STDOUT_SENTINEL}" for stdout.', ) parser.add_argument( "--from-file", type=str, help="Read pre-existing dumpdata JSON from this file instead of running docker exec. " 'Use "-" to read from stdin.', ) parser.add_argument( "--container", default=DEFAULT_CONTAINER_NAME, help="Docker container to dump from (default: %(default)s).", ) parser.add_argument( "--refresh-volatile", action="store_true", help="Take volatile timestamp fields from the new dump instead of " "preserving them from the existing output file.", ) parser.add_argument( "--extra-config", type=Path, help="Path to a JSON file extending the built-in volatile/sortable " "maps and dumpdata excludes. Sections (all optional): " "extra_volatile_fields, extra_sortable_list_fields, extra_excludes. " "Field maps union with the built-in entries per model; " "extra_excludes append to the built-in DUMPDATA_EXCLUDES (and are " "ignored when --from-file is used).", ) return parser def main(argv: list[str] | None = None) -> int: parsed_args = _build_parser().parse_args(argv) if parsed_args.extra_config: try: extra = _load_extra_config(parsed_args.extra_config) except _ConfigError as exc: print(f"error: invalid --extra-config: {exc}", file=sys.stderr) return 2 else: extra = { "extra_volatile_fields": {}, "extra_sortable_list_fields": {}, "extra_excludes": [], } volatile_fields = _merge_field_map(VOLATILE_FIELDS, extra["extra_volatile_fields"]) sortable_list_fields = _merge_field_map( SORTABLE_LIST_FIELDS, extra["extra_sortable_list_fields"] ) excludes = list(DUMPDATA_EXCLUDES) + [ ex for ex in extra["extra_excludes"] if ex not in DUMPDATA_EXCLUDES ] if extra["extra_excludes"] and parsed_args.from_file: print( "warning: extra_excludes is ignored when reading the dump from " "--from-file (the dump is already produced).", file=sys.stderr, ) if parsed_args.from_file == "-": records = json.loads(sys.stdin.read()) elif parsed_args.from_file: records = json.loads(Path(parsed_args.from_file).read_text()) else: records = dump_from_container(parsed_args.container, excludes=excludes) reference_dump: list[dict[str, Any]] | None = None output_path: Path | None = ( None if parsed_args.output == STDOUT_SENTINEL else Path(parsed_args.output) ) if not parsed_args.refresh_volatile and output_path is not None and output_path.exists(): reference_dump = json.loads(output_path.read_text()) normalized = normalize( records, reference=reference_dump, volatile_fields=volatile_fields, sortable_list_fields=sortable_list_fields, ) serialized = json.dumps(normalized, indent=2, ensure_ascii=False) if output_path is None: print(serialized) else: output_path.write_text(serialized + "\n") return 0 if __name__ == "__main__": sys.exit(main())