django-data-shape 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,33 @@
1
+ """A realistically shaped test database from Django models."""
2
+
3
+ from django_data_shape.build import build
4
+ from django_data_shape.build_result import BuildResult
5
+ from django_data_shape.distributions.constant import Constant
6
+ from django_data_shape.distributions.distribution import Distribution
7
+ from django_data_shape.distributions.sequential import Sequential
8
+ from django_data_shape.distributions.skew import Skew
9
+ from django_data_shape.distributions.uniform import Uniform
10
+ from django_data_shape.invalid_shape import InvalidShape
11
+ from django_data_shape.shape import Shape
12
+ from django_data_shape.shape_not_empty import ShapeNotEmpty
13
+ from django_data_shape.table import Table
14
+ from django_data_shape.table_result import TableResult
15
+ from django_data_shape.unsupported_backend import UnsupportedBackend
16
+ from django_data_shape.version import __version__
17
+
18
+ __all__ = [
19
+ "BuildResult",
20
+ "Constant",
21
+ "Distribution",
22
+ "InvalidShape",
23
+ "Sequential",
24
+ "Shape",
25
+ "ShapeNotEmpty",
26
+ "Skew",
27
+ "Table",
28
+ "TableResult",
29
+ "Uniform",
30
+ "UnsupportedBackend",
31
+ "__version__",
32
+ "build",
33
+ ]
@@ -0,0 +1,144 @@
1
+ """Turning a declaration into a database the planner can reason about."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from django.core.management.color import no_style
8
+ from django.db import DEFAULT_DB_ALIAS, connections, transaction
9
+
10
+ from django_data_shape.build_result import BuildResult
11
+ from django_data_shape.generate_rows import generate_rows
12
+ from django_data_shape.require_postgres import require_postgres
13
+ from django_data_shape.shape import Shape
14
+ from django_data_shape.shape_not_empty import ShapeNotEmpty
15
+ from django_data_shape.table import Table
16
+ from django_data_shape.table_result import TableResult
17
+
18
+
19
+ def build(shape: Shape, using: str = DEFAULT_DB_ALIAS) -> BuildResult:
20
+ """Generate, load, reset sequences and analyze every table in ``shape``.
21
+
22
+ The order of those steps is the whole function, and it is not
23
+ interchangeable. Loading rows into a table that was analyzed while empty
24
+ leaves the planner holding statistics from the old contents and applying
25
+ them to the new row count -- a worse lie than having no statistics at all,
26
+ and the one that produced a thirty-thousand-fold misestimate in the
27
+ measurements this package was designed from. So the ``ANALYZE`` is here, at
28
+ the end, owned by the library rather than left to the caller to remember.
29
+
30
+ That is also why a bare ``ANALYZE`` is in this release at all, when the
31
+ statistics work proper -- per-column targets, and caching a built database
32
+ as a template -- comes later. A loader that leaves its table unanalyzed
33
+ ships the exact state this package exists to condemn.
34
+ """
35
+ connection = connections[using]
36
+ require_postgres(connection, "Building a shape")
37
+
38
+ results: list[TableResult] = []
39
+ # One transaction around every table. Without it a shape whose second table
40
+ # fails leaves the first committed and analyzed, and the natural next action
41
+ # -- fix the shape, run it again -- fails on a duplicate key rather than on
42
+ # the original problem.
43
+ with transaction.atomic(using=using):
44
+ for table in shape.tables:
45
+ _require_empty(connection, table)
46
+ loaded = _load(connection, table, shape.seed)
47
+ _reset_sequence(connection, table)
48
+ _analyze(connection, table)
49
+ results.append(TableResult(table=table.db_table, rows=loaded))
50
+ return BuildResult(tables=tuple(results))
51
+
52
+
53
+ def _require_empty(connection: Any, table: Table) -> None:
54
+ """Refuse to build on top of rows that are already there.
55
+
56
+ The keys this package assigns start at 1 every time, so a second build over
57
+ the same table collides on the primary key. That surfaced as a bare
58
+ UniqueViolation naming an index, which tells the reader nothing about what
59
+ they did or what to do instead.
60
+ """
61
+ with connection.cursor() as cursor:
62
+ cursor.execute(f"SELECT EXISTS (SELECT 1 FROM {connection.ops.quote_name(table.db_table)})")
63
+ row = cursor.fetchone()
64
+ if row[0]:
65
+ raise ShapeNotEmpty(
66
+ f"{table.db_table} already holds rows, and this package assigns primary keys from 1, "
67
+ "so building over them would collide. Empty the table first."
68
+ )
69
+
70
+
71
+ # The connection is typed loosely on purpose. ``cursor.copy()`` is psycopg 3's
72
+ # API reached through Django's cursor wrapper, and it appears on no Django base
73
+ # class, so annotating the real wrapper type would mean asserting the checker
74
+ # out of the way on every line that uses it.
75
+ def _load(connection: Any, table: Table, seed: int) -> int:
76
+ """Stream generated rows into the table with ``COPY FROM STDIN``.
77
+
78
+ ``bulk_create`` is the obvious alternative and is roughly an order of
79
+ magnitude too slow at the row counts that make a plan meaningful. ``COPY``
80
+ is also why the generator yields tuples rather than model instances: there
81
+ is no instance to build, and no ``save`` to run.
82
+
83
+ Each declared value is passed through its field's ``get_db_prep_save``
84
+ first, and that is not a formality. Skipping it is how a naive datetime got
85
+ written five hours away from where ``save()`` would have put it under a
86
+ non-UTC ``TIME_ZONE`` -- silently, on the exact column ``Sequential`` exists
87
+ to make realistic -- and how a ``JSONField`` failed to load at all. The
88
+ generator stays backend-neutral because the preparation happens here rather
89
+ than inside it.
90
+
91
+ Returns the number of rows the database actually took, not the number
92
+ declared. They are the same today and stop being so once deduplicated
93
+ many-to-many edges arrive.
94
+ """
95
+ quote = connection.ops.quote_name
96
+ pk_column = table.model._meta.pk.column
97
+ columns = [quote(pk_column)] + [quote(field.column) for _, field in table.columns()]
98
+ statement = f"COPY {quote(table.db_table)} ({', '.join(columns)}) FROM STDIN"
99
+ prepare = [field.get_db_prep_save for _, field in table.columns()]
100
+
101
+ with connection.cursor() as cursor:
102
+ # ``copy`` is not in Django's WRAP_ERROR_ATTRS, so without this a
103
+ # Postgres error escapes as a raw psycopg exception: the caller cannot
104
+ # catch django.db.IntegrityError, and -- worse -- an enclosing atomic
105
+ # block never learns it needs a rollback, so the next query inside it
106
+ # fails with "current transaction is aborted" instead of a Django error.
107
+ with connection.wrap_database_errors, cursor.copy(statement) as copy:
108
+ for row in generate_rows(table, seed):
109
+ copy.write_row(
110
+ (
111
+ row[0],
112
+ *(
113
+ prep(value, connection)
114
+ for prep, value in zip(prepare, row[1:], strict=True)
115
+ ),
116
+ )
117
+ )
118
+ return int(cursor.rowcount)
119
+
120
+
121
+ def _reset_sequence(connection: Any, table: Table) -> None:
122
+ """Move the identity sequence past the keys this package just assigned.
123
+
124
+ Skipping this is the first bug the design invites: rows exist at ids 1..N
125
+ while the sequence still starts at 1, so the very first ``objects.create()``
126
+ inside a test raises ``IntegrityError`` on a primary key that is already
127
+ taken. Django's own backend operation is used rather than a hand-written
128
+ ``setval`` because it already knows how the column's sequence is named.
129
+ """
130
+ with connection.cursor() as cursor:
131
+ for statement in connection.ops.sequence_reset_sql(no_style(), [table.model]):
132
+ cursor.execute(statement)
133
+
134
+
135
+ def _analyze(connection: Any, table: Table) -> None:
136
+ """Populate the statistics the planner reads.
137
+
138
+ Rows alone change nothing: without this the planner falls back to a default
139
+ selectivity and commits to it, which is how a two-million-row table gets
140
+ bitmap-scanned through an index for a value matching 98% of it. Measured at
141
+ 81 ms on that table, because ``ANALYZE`` samples rather than scans.
142
+ """
143
+ with connection.cursor() as cursor:
144
+ cursor.execute(f"ANALYZE {connection.ops.quote_name(table.db_table)}")
@@ -0,0 +1,27 @@
1
+ """What a completed build reports back."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from django_data_shape.table_result import TableResult
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class BuildResult:
12
+ """The outcome of building a shape, table by table.
13
+
14
+ Returned rather than logged because the counts are worth asserting on: the
15
+ row count a shape declares and the row count a table holds are the same
16
+ number today, but they stop being the same as soon as deduplication enters
17
+ the picture with many-to-many edges. Reporting achieved counts from the
18
+ start means that release changes what this says, not what callers have to
19
+ start checking.
20
+ """
21
+
22
+ tables: tuple[TableResult, ...]
23
+
24
+ @property
25
+ def rows(self) -> int:
26
+ """Rows loaded across every table."""
27
+ return sum(result.rows for result in self.tables)
@@ -0,0 +1,9 @@
1
+ """The declared value distributions."""
2
+
3
+ from django_data_shape.distributions.constant import Constant
4
+ from django_data_shape.distributions.distribution import Distribution
5
+ from django_data_shape.distributions.sequential import Sequential
6
+ from django_data_shape.distributions.skew import Skew
7
+ from django_data_shape.distributions.uniform import Uniform
8
+
9
+ __all__ = ["Constant", "Distribution", "Sequential", "Skew", "Uniform"]
@@ -0,0 +1,26 @@
1
+ """The same value in every row."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class Constant:
7
+ """Every row gets ``value``.
8
+
9
+ Present because a column that never varies is a real shape, not a missing
10
+ declaration: a tenant id on a single-tenant fixture, or a flag that is false
11
+ for every row in the dataset under test. Declaring it says so, where leaving
12
+ it out would mean the field simply had no distribution.
13
+
14
+ It also has a planner consequence worth knowing: a single-valued column has
15
+ exactly one most-common value at frequency 1.0, so a filter on it is either
16
+ everything or nothing, and an index on it is never usable.
17
+ """
18
+
19
+ def __init__(self, value: object) -> None:
20
+ self._value = value
21
+
22
+ def value(self, row: int, draw: float) -> object:
23
+ return self._value
24
+
25
+ def __repr__(self) -> str:
26
+ return f"Constant({self._value!r})"
@@ -0,0 +1,24 @@
1
+ """The one thing every declared value has in common."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+
8
+ class Distribution(Protocol):
9
+ """Produces the value of one field for one row.
10
+
11
+ Both arguments are supplied to every implementation because the two kinds of
12
+ distribution need different halves: a categorical or numeric one consumes
13
+ ``draw`` and ignores the row, while a monotonic one consumes ``row`` and
14
+ ignores the draw. Passing both keeps the protocol single-method, and a
15
+ single-method protocol is what allows a distribution to be a plain object
16
+ rather than a class hierarchy.
17
+
18
+ ``draw`` is uniform in [0, 1) and depends only on the field and the row, so
19
+ an implementation must not carry state between calls. One that did would
20
+ make the same shape produce different data depending on generation order,
21
+ which is the property the placement work in a later release depends on.
22
+ """
23
+
24
+ def value(self, row: int, draw: float) -> object: ...
@@ -0,0 +1,31 @@
1
+ """A column that advances with the row, instead of scattering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class Sequential:
9
+ """``start`` plus ``row`` steps: monotonic, and correlated with the key.
10
+
11
+ The point is the correlation, not the convenience. Postgres records a
12
+ correlation statistic per column and costs an index scan differently
13
+ depending on it, so a timestamp column filled with shuffled dates plans
14
+ differently from one that advances the way real rows arrive. Shuffling is
15
+ the easy thing to do by accident and it is wrong in a way that only shows up
16
+ in plan choice.
17
+
18
+ Works for anything supporting ``start + row * step``, which covers numbers
19
+ and ``datetime`` with a ``timedelta``. It ignores ``draw`` entirely: this is
20
+ the one distribution whose value is a function of position alone.
21
+ """
22
+
23
+ def __init__(self, start: Any, step: Any) -> None:
24
+ self._start = start
25
+ self._step = step
26
+
27
+ def value(self, row: int, draw: float) -> object:
28
+ return self._start + row * self._step
29
+
30
+ def __repr__(self) -> str:
31
+ return f"Sequential({self._start!r}, {self._step!r})"
@@ -0,0 +1,71 @@
1
+ """A categorical column with a declared imbalance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from django_data_shape.invalid_shape import InvalidShape
10
+
11
+
12
+ class Skew:
13
+ """Values drawn from a weighted set, in a fixed order.
14
+
15
+ This is the distribution the package exists for. A status column that is 98%
16
+ one value is what decides whether an index on it is usable at all, and it is
17
+ the thing a fixtures loop never expresses -- ten rows with one of each says
18
+ the opposite of what production says.
19
+
20
+ ``Mapping[Any, float]`` rather than ``dict[object, float]``: ``dict`` is
21
+ invariant in both parameters, so a caller's prepared ``dict[str, float]`` --
22
+ the obvious way to build one of these outside a call -- would be rejected by
23
+ a type checker for no reason a reader could act on. ``Mapping`` is covariant
24
+ in its value type, so integer counts are accepted too.
25
+
26
+ Weights are relative and need not sum to 1: the readable form is often
27
+ counts, and normalising here is cheaper than making every caller do it. They
28
+ must be positive, because a zero-weight value is a value that never appears,
29
+ which is better said by leaving it out than by declaring it and meaning not.
30
+ """
31
+
32
+ def __init__(self, weights: Mapping[Any, float]) -> None:
33
+ if not weights:
34
+ raise InvalidShape("Skew needs at least one value; an empty distribution has none.")
35
+ # ``not (w > 0)`` rather than ``w <= 0`` because NaN compares False to
36
+ # both, so the obvious spelling let it through -- and a NaN weight makes
37
+ # every cumulative bound NaN, so no draw ever matches and the fallthrough
38
+ # returns the last value for every row. The declared distribution comes
39
+ # out inverted, silently.
40
+ bad = sorted(repr(v) for v, w in weights.items() if not (w > 0) or not math.isfinite(w))
41
+ if bad:
42
+ raise InvalidShape(
43
+ "Skew weights must be positive and finite, and these are not: "
44
+ + ", ".join(bad)
45
+ + ". A value that never occurs is said by omitting it."
46
+ )
47
+ total = sum(weights.values())
48
+ # The cumulative bounds are precomputed once rather than per row: this
49
+ # runs a million times or more, and the alternative is re-summing the
50
+ # weights inside the hot loop.
51
+ self._values: list[object] = []
52
+ self._bounds: list[float] = []
53
+ running = 0.0
54
+ for value, weight in weights.items():
55
+ running += weight
56
+ self._values.append(value)
57
+ self._bounds.append(running / total)
58
+ self._weights = dict(weights)
59
+
60
+ def value(self, row: int, draw: float) -> object:
61
+ for value, bound in zip(self._values, self._bounds, strict=True):
62
+ if draw < bound:
63
+ return value
64
+ # Reachable only when floating-point accumulation leaves the final bound
65
+ # a hair below 1.0, since a draw is guaranteed below 1.0 by construction.
66
+ # The last value is the correct answer there, and falling through to it
67
+ # is cheaper than renormalising on every draw.
68
+ return self._values[-1]
69
+
70
+ def __repr__(self) -> str:
71
+ return f"Skew({self._weights!r})"
@@ -0,0 +1,50 @@
1
+ """A numeric column spread evenly across a range."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from decimal import Decimal
7
+
8
+ from django_data_shape.invalid_shape import InvalidShape
9
+
10
+
11
+ class Uniform:
12
+ """Values spread evenly between ``low`` and ``high``.
13
+
14
+ Deliberately the least interesting distribution in the package, and named
15
+ plainly so it reads as a choice. Most real columns are not uniform, and a
16
+ uniform declaration on a column that matters is usually a placeholder
17
+ somebody meant to come back to.
18
+
19
+ ``places`` rounds the result. Not because the column would reject the
20
+ unrounded value -- Postgres rounds a float to a ``numeric(10, 2)`` happily,
21
+ and only overflowing the declared precision is an error -- but because a
22
+ money column whose values carry full binary float noise is not what the
23
+ application would ever have written, and this package's whole claim is that
24
+ the loaded rows are ones it could have. Rounding to ``Decimal`` rather than
25
+ ``float`` keeps the value exact on the way into ``COPY``; ``places=0`` is
26
+ how a plain integer column is filled.
27
+ """
28
+
29
+ def __init__(self, low: float, high: float, places: int | None = None) -> None:
30
+ # isfinite first: NaN compares False to every ordering, so ``high <= low``
31
+ # accepts a NaN bound and then returns NaN for every row.
32
+ if not math.isfinite(low) or not math.isfinite(high):
33
+ raise InvalidShape(f"Uniform needs finite bounds, got low={low}, high={high}.")
34
+ if high <= low:
35
+ raise InvalidShape(f"Uniform needs high greater than low, got low={low}, high={high}.")
36
+ if places is not None and places < 0:
37
+ raise InvalidShape(f"Uniform places cannot be negative, got {places}.")
38
+ self._low = low
39
+ self._high = high
40
+ self._places = places
41
+
42
+ def value(self, row: int, draw: float) -> object:
43
+ raw = self._low + draw * (self._high - self._low)
44
+ if self._places is None:
45
+ return raw
46
+ return round(Decimal(repr(raw)), self._places)
47
+
48
+ def __repr__(self) -> str:
49
+ places = "" if self._places is None else f", places={self._places}"
50
+ return f"Uniform({self._low!r}, {self._high!r}{places})"
@@ -0,0 +1,43 @@
1
+ """Turning a declared table into the tuples COPY will consume."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ from django_data_shape.table import Table
9
+ from django_data_shape.utils import draw, field_stream
10
+
11
+
12
+ def generate_rows(table: Table, seed: int) -> Iterator[tuple[Any, ...]]:
13
+ """Yield one tuple per row: the primary key, then each declared column.
14
+
15
+ Rows, not model instances. The ORM is the wrong tool at these counts -- it
16
+ is the difference between a load measured in seconds and one measured in
17
+ minutes -- and nothing here needs a model instance, because no ``save`` will
18
+ run and no signal should fire.
19
+
20
+ Primary keys are a dense ``1..N`` because this package assigns them. That is
21
+ what will let a child row's foreign key be satisfied by construction with no
22
+ lookup, and it is why a self-referential tree is acyclic for free. It also
23
+ obliges the caller to reset the sequence afterwards; see ``build``.
24
+
25
+ A generator rather than a list: a million rows of tuples is real memory, and
26
+ psycopg writes them one at a time anyway, so materialising the whole set
27
+ would buy nothing but peak RSS.
28
+ """
29
+ columns = table.columns()
30
+ # Stream ids are derived once per field rather than per row. At a million
31
+ # rows this loop runs a million times per column, so anything hoistable out
32
+ # of it is worth hoisting.
33
+ streams = [field_stream(seed, table.db_table, name) for name, _ in columns]
34
+ distributions = [table.fields[name] for name, _ in columns]
35
+
36
+ for row in range(table.rows):
37
+ yield (
38
+ row + 1,
39
+ *(
40
+ distribution.value(row, draw(stream, row))
41
+ for distribution, stream in zip(distributions, streams, strict=True)
42
+ ),
43
+ )
@@ -0,0 +1,18 @@
1
+ """Raised when a declaration cannot describe a database."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class InvalidShape(Exception):
7
+ """A shape declaration is contradictory, incomplete or unsatisfiable.
8
+
9
+ Its own type, and raised as early as the contradiction can be seen -- at
10
+ declaration time wherever possible, rather than at load time. The reason is
11
+ the package's own bar: a generated database that is wrong is worse than one
12
+ that refuses to exist, because the test suite it feeds will assert on data
13
+ that could never occur and pass or fail for reasons unrelated to the code.
14
+
15
+ Every message names the model, the field or the constraint at fault. An
16
+ error that says only that something is inconsistent leaves the reader to
17
+ re-derive what this code already knew.
18
+ """
File without changes
@@ -0,0 +1,46 @@
1
+ """The backend gate, in one place and testable without a database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from django_data_shape.unsupported_backend import UnsupportedBackend
8
+
9
+
10
+ def require_postgres(connection: Any, operation: str) -> None:
11
+ """Refuse anything but PostgreSQL, naming what was refused and why.
12
+
13
+ Takes the connection and reads ``vendor`` off it rather than importing a
14
+ backend or opening a cursor, which is what lets every refusal path in this
15
+ package be covered by passing an object with a vendor. A degradation path
16
+ reachable only by running the whole suite on the backend it refuses is a
17
+ path the coverage gate cannot see, and this package gates coverage on
18
+ Postgres precisely because that is where its real work happens.
19
+ """
20
+ # The driver is read off the connection, exactly like ``vendor`` above, and
21
+ # for the same reason: a refusal that could only be covered by installing
22
+ # the driver it refuses is a refusal the coverage gate cannot see. Django
23
+ # sets ``Database`` to the driver module -- ``psycopg`` on 3, ``psycopg2``
24
+ # on 2 -- so a stub can supply it and this branch is testable everywhere.
25
+ driver = getattr(connection.Database, "__name__", "")
26
+ if connection.vendor == "postgresql" and driver != "psycopg":
27
+ # Django 6.1 still ships the psycopg 2 fallback, so this is a live
28
+ # configuration rather than a legacy one. Without this check the vendor
29
+ # gate passes and the load fails deep inside the package with
30
+ # "'psycopg2.extensions.cursor' object has no attribute 'copy'" -- a
31
+ # traceback pointing here, which a user would reasonably file as a bug
32
+ # in this package rather than as a missing driver.
33
+ raise UnsupportedBackend(
34
+ f"{operation} needs psycopg 3; connection '{connection.alias}' is using "
35
+ f"{driver or 'an unknown driver'}. "
36
+ "Rows are streamed straight into COPY FROM STDIN, which psycopg 2 cannot do without "
37
+ "materialising them first -- the cost this package exists to avoid. Install the "
38
+ "'postgres' extra: pip install django-data-shape[postgres]."
39
+ )
40
+ if connection.vendor != "postgresql":
41
+ raise UnsupportedBackend(
42
+ f"{operation} needs PostgreSQL; connection '{connection.alias}' is "
43
+ f"{connection.vendor}. Generation and cardinality are backend-neutral, but "
44
+ "COPY loading and planner statistics are not, and a shaped database whose plans "
45
+ "mean nothing is worse than no shaped database at all."
46
+ )
@@ -0,0 +1,42 @@
1
+ """A whole declared database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django_data_shape.invalid_shape import InvalidShape
6
+ from django_data_shape.table import Table
7
+
8
+
9
+ class Shape:
10
+ """The tables to build, and the seed that makes them reproducible.
11
+
12
+ A declaration and nothing else: it holds no connection, opens nothing, and
13
+ has no ``build`` method. Building lives in a separate function on purpose,
14
+ because a shape has to stay inert data for two things that come later --
15
+ hashing it into a template-database cache key, and emitting one from a real
16
+ database's statistics. An object that could act would be an object with
17
+ state worth not hashing.
18
+
19
+ The seed is part of the declaration rather than an argument to the build,
20
+ for the same reason: two builds of the same shape must produce byte-identical
21
+ databases, and a seed passed at build time would let them differ while the
22
+ declaration claimed they could not.
23
+ """
24
+
25
+ def __init__(self, *tables: Table, seed: int = 0) -> None:
26
+ if not tables:
27
+ raise InvalidShape("A shape needs at least one table.")
28
+ seen: dict[str, Table] = {}
29
+ for table in tables:
30
+ key = table.db_table
31
+ if key in seen:
32
+ raise InvalidShape(
33
+ f"{key} is declared twice. One table gets one row count and one set of "
34
+ "distributions; two declarations would silently mean whichever came last."
35
+ )
36
+ seen[key] = table
37
+ self.tables = tables
38
+ self.seed = seed
39
+
40
+ def __repr__(self) -> str:
41
+ names = ", ".join(table.model.__name__ for table in self.tables)
42
+ return f"Shape({names}, seed={self.seed})"
@@ -0,0 +1,17 @@
1
+ """Raised when a table already holds rows a build would collide with."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ShapeNotEmpty(Exception):
7
+ """The destination table is not empty, so the assigned keys would collide.
8
+
9
+ Its own type rather than a reused one because the caller's remedy is
10
+ specific and nothing else in this package shares it: empty the table, then
11
+ build. Raised before any row is written, so a build that fails this way has
12
+ changed nothing.
13
+
14
+ The alternative was letting the database report it, which it did -- as a
15
+ unique-violation naming an index. That says what went wrong at the storage
16
+ layer and nothing about what the caller did or what to do instead.
17
+ """
@@ -0,0 +1,207 @@
1
+ """One model's declared shape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ from django.db.models import Field, IntegerField, Model
8
+ from django.db.models.fields import NOT_PROVIDED
9
+
10
+ from django_data_shape.distributions.constant import Constant
11
+ from django_data_shape.distributions.distribution import Distribution
12
+ from django_data_shape.invalid_shape import InvalidShape
13
+
14
+
15
+ class Table:
16
+ """How many rows of one model, and how each column is distributed.
17
+
18
+ Field distributions are given as keyword arguments because that is the form
19
+ a reader scans fastest. ``fields=`` is the escape hatch, and it is not
20
+ optional politeness: a model may legitimately have a column called ``rows``
21
+ or ``model``, and Python's own argument binding would silently hand that
22
+ keyword to this signature instead. Without the mapping form those models
23
+ would simply be undeclarable.
24
+
25
+ Every refusal below happens here, at declaration time, rather than during
26
+ the load. A shape that cannot describe a database should say so before it
27
+ has spent a minute generating rows, and the message should name the field --
28
+ a reader who has to re-derive which column was meant has been given an error
29
+ that knows more than it says.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ model: type[Model],
35
+ rows: int,
36
+ fields: dict[str, Distribution] | None = None,
37
+ **field_distributions: Distribution,
38
+ ) -> None:
39
+ if rows < 0:
40
+ raise InvalidShape(f"{model.__name__} cannot have {rows} rows.")
41
+
42
+ declared: dict[str, Distribution] = dict(fields or {})
43
+ overlap = sorted(set(declared) & set(field_distributions))
44
+ if overlap:
45
+ raise InvalidShape(
46
+ f"{model.__name__} declares {', '.join(overlap)} twice, once in fields= and "
47
+ "once as a keyword. Use one or the other."
48
+ )
49
+ declared.update(field_distributions)
50
+
51
+ self.model = model
52
+ self.rows = rows
53
+ self.fields = declared
54
+ self._validate()
55
+
56
+ @property
57
+ def db_table(self) -> str:
58
+ return str(self.model._meta.db_table)
59
+
60
+ def columns(self) -> tuple[tuple[str, Field[Any, Any]], ...]:
61
+ """The declared fields, in a stable order, with their model fields.
62
+
63
+ Sorted by name rather than left in declaration order: the order decides
64
+ the column list of the ``COPY`` statement, and a shape whose generated
65
+ SQL changes when two keyword arguments are swapped would hash to a
66
+ different cache key for no reason.
67
+ """
68
+ meta = self.model._meta
69
+ # cast because get_field's return type covers reverse relations too,
70
+ # which _validate has already ruled out for every name reaching here.
71
+ return tuple(
72
+ (name, cast("Field[Any, Any]", meta.get_field(name))) for name in sorted(self.fields)
73
+ )
74
+
75
+ def _validate(self) -> None:
76
+ meta = self.model._meta
77
+ known = {field.name: field for field in meta.concrete_fields}
78
+
79
+ unknown = sorted(name for name in self.fields if name not in known)
80
+ if unknown:
81
+ raise InvalidShape(
82
+ f"{self.model.__name__} has no field named {', '.join(unknown)}. "
83
+ f"Its concrete fields are: {', '.join(sorted(known))}."
84
+ )
85
+
86
+ pk_fields = [field for field in known.values() if field.primary_key]
87
+ for field in pk_fields:
88
+ # The dense 1..N range this package assigns is integers, and nothing
89
+ # downstream converts it. Given a CharField primary key the load
90
+ # used to succeed and write "1", "2", "3" -- values the application
91
+ # could never produce, with a whole statistics picture built on top
92
+ # of them. Refusing is the only honest answer until a key strategy
93
+ # can be declared per table.
94
+ if not isinstance(field, IntegerField):
95
+ raise InvalidShape(
96
+ f"{self.model.__name__}.{field.name} is a "
97
+ f"{type(field).__name__} primary key, and this package assigns primary keys "
98
+ "itself as a dense 1..N integer range. Only integer primary keys are "
99
+ "supported."
100
+ )
101
+
102
+ pk_names = {name for name, field in known.items() if field.primary_key}
103
+ declared_pk = sorted(pk_names & set(self.fields))
104
+ if declared_pk:
105
+ raise InvalidShape(
106
+ f"{self.model.__name__}.{', '.join(declared_pk)} is the primary key, which this "
107
+ "package assigns itself as a dense 1..N range. That is what lets a foreign key "
108
+ "be satisfied without a lookup, so it is not available to declare."
109
+ )
110
+
111
+ # Relations are the next release's work, and generating a foreign key
112
+ # column from a value distribution would produce ids pointing at rows
113
+ # that may not exist. Refusing is the only honest answer until fan-out
114
+ # can be declared as a distribution over the parents.
115
+ relations = sorted(
116
+ name for name, field in known.items() if field.is_relation and name in self.fields
117
+ )
118
+ if relations:
119
+ raise InvalidShape(
120
+ f"{self.model.__name__}.{', '.join(relations)} is a relation, and relations are "
121
+ "not supported yet. Declaring fan-out as a distribution is the next release."
122
+ )
123
+
124
+ self._resolve_defaults(known)
125
+
126
+ def _resolve_defaults(self, known: dict[str, Field[Any, Any]]) -> None:
127
+ """Decide what happens to every field the caller did not declare.
128
+
129
+ The subtlety that makes this its own method: **a Django ``default=`` is
130
+ not a database default.** It is applied by ``save()``, and this package
131
+ never calls ``save()`` -- it streams tuples into ``COPY``. So a column
132
+ that is ``NOT NULL`` with a Python-level default has nothing behind it
133
+ at the database level, and omitting it from the load fails on a
134
+ not-null violation rather than quietly taking the default.
135
+
136
+ Filling it in with the same value ``save()`` would have written keeps
137
+ the caller's mental model true instead of making them restate a default
138
+ they already declared on the model.
139
+ """
140
+ undeclared = [
141
+ (name, field)
142
+ for name, field in known.items()
143
+ if name not in self.fields and not field.primary_key
144
+ ]
145
+
146
+ # Declaring a relation is refused in _validate; omitting a required one
147
+ # used to be accepted and then fail inside COPY with a not-null
148
+ # violation. Both directions have to refuse, or the contract only holds
149
+ # for the callers who tried the unsupported thing explicitly.
150
+ required_relations = sorted(
151
+ name
152
+ for name, field in undeclared
153
+ if field.is_relation and not field.null and not field.has_default()
154
+ )
155
+ if required_relations:
156
+ raise InvalidShape(
157
+ f"{self.model.__name__}.{', '.join(required_relations)} is a relation that "
158
+ "cannot be null, and relations are not supported yet, so this shape cannot be "
159
+ "built. Declaring fan-out as a distribution is the next release."
160
+ )
161
+ undeclared = [(name, field) for name, field in undeclared if not field.is_relation]
162
+
163
+ # A callable default is refused rather than guessed. This package cannot
164
+ # know whether it varies per row -- ``uuid4`` does, ``dict`` does not --
165
+ # and both readings produce data the application would never have
166
+ # written: one duplicates a value meant to be unique, the other invents
167
+ # variation where the model promised none.
168
+ callables = sorted(
169
+ name for name, field in undeclared if field.has_default() and callable(field.default)
170
+ )
171
+ if callables:
172
+ raise InvalidShape(
173
+ f"{self.model.__name__}.{', '.join(callables)} has a callable default, which "
174
+ "this package will not call on your behalf: it cannot tell a per-row default "
175
+ "from a shared one, and guessing either way writes rows the application never "
176
+ "would. Declare a distribution for it."
177
+ )
178
+
179
+ missing: list[str] = []
180
+ for name, field in undeclared:
181
+ if field.has_default():
182
+ self.fields[name] = Constant(field.get_default())
183
+ elif field.null or self._has_db_default(field):
184
+ continue
185
+ else:
186
+ missing.append(name)
187
+
188
+ if missing:
189
+ raise InvalidShape(
190
+ f"{self.model.__name__}.{', '.join(sorted(missing))} cannot be null and has no "
191
+ "default, so it has to be declared. A column left to chance is the column whose "
192
+ "selectivity the plan then depends on."
193
+ )
194
+
195
+ @staticmethod
196
+ def _has_db_default(field: Field[Any, Any]) -> bool:
197
+ """Whether the database itself will supply a value.
198
+
199
+ ``db_default`` arrived in Django 5.0 and this package supports 4.2, so
200
+ the attribute cannot be assumed to exist. Unlike ``default``, this one
201
+ is real DDL, which is why a column carrying it can be left out of the
202
+ ``COPY`` entirely.
203
+ """
204
+ return getattr(field, "db_default", NOT_PROVIDED) is not NOT_PROVIDED
205
+
206
+ def __repr__(self) -> str:
207
+ return f"Table({self.model.__name__}, rows={self.rows})"
@@ -0,0 +1,18 @@
1
+ """What one table's load actually produced."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class TableResult:
10
+ """Rows loaded into one table.
11
+
12
+ Carries the database table name rather than the model, because this is what
13
+ a caller prints or asserts on, and because the two stop being one-to-one as
14
+ soon as through tables are generated alongside the models that declare them.
15
+ """
16
+
17
+ table: str
18
+ rows: int
@@ -0,0 +1,18 @@
1
+ """Raised when the connection cannot do what was asked of it."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class UnsupportedBackend(Exception):
7
+ """The database backend cannot support the operation requested.
8
+
9
+ Separate from :class:`~django_data_shape.invalid_shape.InvalidShape`
10
+ because nothing is wrong with the declaration: the same shape is valid, and
11
+ would build, against Postgres. Only the destination is unsuitable.
12
+
13
+ Raised rather than warned, and never quietly degraded to a slower path. The
14
+ whole claim of this package is that the loaded database is one the planner
15
+ can reason about; a backend without ``COPY`` or column statistics cannot
16
+ produce that, and silently producing something else would be the failure
17
+ mode the package was written to expose.
18
+ """
@@ -0,0 +1,45 @@
1
+ """Helpers used across more than one module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ _MASK64 = (1 << 64) - 1
8
+ _GOLDEN = 0x9E3779B97F4A7C15
9
+
10
+
11
+ def field_stream(seed: int, table: str, field: str) -> int:
12
+ """A stable 64-bit stream id for one table's one field.
13
+
14
+ Derived once per field rather than per row, so the cost of a real hash is
15
+ paid a handful of times instead of millions. ``hash()`` is deliberately not
16
+ used: it is salted per interpreter run, which would make a seeded shape
17
+ reproduce only within a single process.
18
+ """
19
+ digest = hashlib.blake2b(f"{table}.{field}".encode(), digest_size=8).digest()
20
+ return (int.from_bytes(digest, "big") ^ seed) & _MASK64
21
+
22
+
23
+ def draw(stream: int, row: int) -> float:
24
+ """A uniform float in [0, 1) for one field of one row.
25
+
26
+ Deterministic in ``(stream, row)`` alone, which is the property the whole
27
+ design rests on: a value does not depend on how many rows were generated
28
+ before it, so rows can later be emitted in an order different from the one
29
+ they were assigned in. That separation is what lets physical placement be
30
+ declared without buffering a group in memory.
31
+
32
+ SplitMix64's finalizer rather than ``random.Random``: seeding a Mersenne
33
+ Twister per value costs far more than the value is worth at these row
34
+ counts, and this needs no sequential state to be reproducible.
35
+ """
36
+ z = (stream + (row + 1) * _GOLDEN) & _MASK64
37
+ z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & _MASK64
38
+ z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & _MASK64
39
+ z = z ^ (z >> 31)
40
+ # 53 bits over 2**53 rather than 64 bits over 2**64: the latter can round up
41
+ # to exactly 1.0 for the top 1024 finalizer outputs, and the finalizer is a
42
+ # bijection so all of them are reachable. That would break the [0, 1)
43
+ # contract every distribution is written against. This is the same
44
+ # construction the standard library uses for ``random.random()``.
45
+ return (z >> 11) / 9007199254740992.0
@@ -0,0 +1,7 @@
1
+ """The single source of truth for this package's version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__: str = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-data-shape
3
+ Version: 0.1.0
4
+ Summary: A realistically shaped test database from Django models: declare cardinality, skew and fan-out, load by COPY, and make the query planner believe it.
5
+ Project-URL: Homepage, https://github.com/Artui/django-data-shape
6
+ Project-URL: Repository, https://github.com/Artui/django-data-shape
7
+ Project-URL: Issues, https://github.com/Artui/django-data-shape/issues
8
+ Author-email: Artur Veres <artur8118@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: django,fixtures,performance,postgres,query-planner,test-data,testing
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 4.2
16
+ Classifier: Framework :: Django :: 5.0
17
+ Classifier: Framework :: Django :: 5.1
18
+ Classifier: Framework :: Django :: 5.2
19
+ Classifier: Framework :: Django :: 6.0
20
+ Classifier: Framework :: Django :: 6.1
21
+ Classifier: Framework :: Pytest
22
+ Classifier: Intended Audience :: Developers
23
+ Classifier: License :: OSI Approved :: MIT License
24
+ Classifier: Operating System :: OS Independent
25
+ Classifier: Programming Language :: Python
26
+ Classifier: Programming Language :: Python :: 3
27
+ Classifier: Programming Language :: Python :: 3.10
28
+ Classifier: Programming Language :: Python :: 3.11
29
+ Classifier: Programming Language :: Python :: 3.12
30
+ Classifier: Programming Language :: Python :: 3.13
31
+ Classifier: Programming Language :: Python :: 3.14
32
+ Classifier: Topic :: Database
33
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
34
+ Classifier: Topic :: Software Development :: Testing
35
+ Requires-Python: >=3.10
36
+ Requires-Dist: django>=4.2
37
+ Provides-Extra: postgres
38
+ Requires-Dist: psycopg[binary]>=3.2.0; extra == 'postgres'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # django-data-shape
42
+
43
+ [![CI](https://github.com/Artui/django-data-shape/workflows/tests/badge.svg)](https://github.com/Artui/django-data-shape/actions/workflows/tests.yml)
44
+ [![PyPI](https://img.shields.io/pypi/v/django-data-shape.svg)](https://pypi.org/project/django-data-shape/)
45
+ [![Python versions](https://img.shields.io/pypi/pyversions/django-data-shape.svg)](https://pypi.org/project/django-data-shape/)
46
+ [![Django versions](https://img.shields.io/pypi/djversions/django-data-shape.svg)](https://pypi.org/project/django-data-shape/)
47
+ [![Docs](https://img.shields.io/badge/docs-artui.github.io-blue.svg)](https://artui.github.io/django-data-shape/)
48
+ [![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Artui/django-data-shape/gh-pages/coverage.json)](https://github.com/Artui/django-data-shape/actions/workflows/tests.yml)
49
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
50
+ [![License](https://img.shields.io/pypi/l/django-data-shape.svg)](LICENSE)
51
+
52
+ A realistically shaped test database from Django models.
53
+
54
+ Declare the shape of your data -- cardinality, value skew, foreign-key fan-out as
55
+ a distribution with a long tail, and where related rows physically sit -- then
56
+ load it by `COPY` and `ANALYZE` it, so the query planner makes the same choices
57
+ it will make in production.
58
+
59
+ It exists because a plan over ten rows is a lie, and because the loop it replaces
60
+ is not merely smaller: uniform fan-out makes the planner always right, and
61
+ generating children parent-by-parent clusters them perfectly, which flatters
62
+ every index scan. A test database can be wrong in the flattering direction, and
63
+ usually is.
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ pip install django-data-shape[postgres]
69
+ ```
70
+
71
+ ## Use
72
+
73
+ ```python
74
+ import datetime
75
+
76
+ from django_data_shape import Sequential, Shape, Skew, Table, Uniform, build
77
+
78
+ shape = Shape(
79
+ Table(
80
+ Order,
81
+ rows=1_000_000,
82
+ status=Skew({"complete": 0.98, "pending": 0.015, "cancelled": 0.005}),
83
+ total=Uniform(0, 500, places=2),
84
+ created_at=Sequential(
85
+ datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc),
86
+ datetime.timedelta(seconds=3),
87
+ ),
88
+ ),
89
+ seed=1234,
90
+ )
91
+
92
+ build(shape)
93
+ ```
94
+
95
+ `build()` generates the rows, loads them with `COPY`, moves the identity sequence
96
+ past the keys it assigned, and runs `ANALYZE` so the planner can see the shape.
97
+ It raises on any backend that is not PostgreSQL rather than degrading quietly.
98
+
99
+ ## Status
100
+
101
+ Early. This release covers single tables. Foreign-key fan-out as a distribution,
102
+ physical placement, per-group invariants and template-database reuse are the
103
+ releases after it; declaring a relation raises today rather than generating ids
104
+ that point at nothing.
105
+
106
+ Full documentation: <https://artui.github.io/django-data-shape/>
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,24 @@
1
+ django_data_shape/__init__.py,sha256=_IVSrdleAm0zSEg0CZRlsPzMLWp7XYt5w56xx5SC8cQ,1107
2
+ django_data_shape/build.py,sha256=rCzCPBmQafifRS6wB_9oOpquoa74Y0BV3asr1ZK95Nw,7042
3
+ django_data_shape/build_result.py,sha256=z_I8DeVO9YC-pOztXAz6dk-1iFho2gf1J_3JeGU4-Kc,871
4
+ django_data_shape/generate_rows.py,sha256=weXqNSA-ANKfbZe9ub66RoMPvKVRBxlOshTIFXaD_gI,1821
5
+ django_data_shape/invalid_shape.py,sha256=xC9sUqiLo5EkDMvFoBpqeqIiZTOlDQyDl5AXPIetqOU,804
6
+ django_data_shape/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ django_data_shape/require_postgres.py,sha256=S5y3PPwfa0DpRskXamFzt-L0F0P7yQm0W2hlM6UCEwY,2618
8
+ django_data_shape/shape.py,sha256=2hcASfhoRHNz6JsE_m4T2tH4p7NpHZl741gc8Dmp85E,1727
9
+ django_data_shape/shape_not_empty.py,sha256=0fNK9IjiBiZ14gWPEnXVwNfTdTBE-CVTtyjX06oSZAw,712
10
+ django_data_shape/table.py,sha256=uzViWZogjpA1qRR-thomG2kuIh4zR2aC8E6ohDyPrpg,9553
11
+ django_data_shape/table_result.py,sha256=f5M3irpbCy4m0sUSwc0iVDhCL5zXXmLAnQ7tiC-bxrc,478
12
+ django_data_shape/unsupported_backend.py,sha256=38lea85ttw8yLs83i8yU0AtdI3la4wkH3oAidrNK3j4,799
13
+ django_data_shape/utils.py,sha256=3rdIL0KTBRATL6fyoIEjO0YVhlv2ezJF4fTxygr_AhM,2001
14
+ django_data_shape/version.py,sha256=IGt2-6r71nTeUfPEK_x1lNeMEBrz0uYGAfUvfX0lTVs,152
15
+ django_data_shape/distributions/__init__.py,sha256=_qvSk36zHtq33S0ewBJCZjlQR9eaWWeXJOThnj2MhIQ,426
16
+ django_data_shape/distributions/constant.py,sha256=lwEcfqrtaAdRdIrClnw-nT5UswUKSFQT2ZiLp773Tlk,889
17
+ django_data_shape/distributions/distribution.py,sha256=gjcpRo2eFY2UaUDJwHzbgN8nHK4W3ijxZW2m1Lb6H4M,1009
18
+ django_data_shape/distributions/sequential.py,sha256=Ag7sbIURwG3_47sKfJVnGSNs6rEX-Mw-_x4j3UH9FCQ,1170
19
+ django_data_shape/distributions/skew.py,sha256=KbjYPxUUMPr_r0bMlpTVyho5Hh5WDPLLReRDpxcbzYA,3297
20
+ django_data_shape/distributions/uniform.py,sha256=p-RC0AENioKBCJjEEYscH1Cmgg7cPlYAjwl7Q20asIo,2245
21
+ django_data_shape-0.1.0.dist-info/METADATA,sha256=qgtGCYr9JuILW_4MirC7KzIwliLZ41jU0FkFJsqfnEw,4693
22
+ django_data_shape-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
23
+ django_data_shape-0.1.0.dist-info/licenses/LICENSE,sha256=0rCDnUPa87Fm67SxYMrdqEdLV6UiMENH6NCPLZ_KT5M,1068
24
+ django_data_shape-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artur Veres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.