nini-datacomp 0.1.0__py3-none-win_amd64.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.
- nini_datacomp/__init__.py +55 -0
- nini_datacomp/_native.py +121 -0
- nini_datacomp/_signatures.py +27 -0
- nini_datacomp/api.py +250 -0
- nini_datacomp/core/__init__.py +1 -0
- nini_datacomp/core/allocator.py +60 -0
- nini_datacomp/core/arrow.py +207 -0
- nini_datacomp/core/arrow_c.py +374 -0
- nini_datacomp/core/blocks.py +112 -0
- nini_datacomp/core/compare.py +203 -0
- nini_datacomp/core/keys.py +138 -0
- nini_datacomp/core/memory.py +73 -0
- nini_datacomp/core/rowhash.py +194 -0
- nini_datacomp/datacomp.dll +0 -0
- nini_datacomp/errors.py +17 -0
- nini_datacomp/io.py +130 -0
- nini_datacomp/options.py +101 -0
- nini_datacomp/pipeline.py +824 -0
- nini_datacomp/py.typed +0 -0
- nini_datacomp/report.py +595 -0
- nini_datacomp/results.py +256 -0
- nini_datacomp/windows.py +530 -0
- nini_datacomp-0.1.0.dist-info/METADATA +248 -0
- nini_datacomp-0.1.0.dist-info/RECORD +27 -0
- nini_datacomp-0.1.0.dist-info/WHEEL +5 -0
- nini_datacomp-0.1.0.dist-info/licenses/LICENSE +201 -0
- nini_datacomp-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""nini-datacomp: comparação de tabelas em escala, com kernels C++ sobre as colunas do Polars.
|
|
2
|
+
|
|
3
|
+
Reescrita do datacompolars::
|
|
4
|
+
|
|
5
|
+
import nini_datacomp as dc
|
|
6
|
+
|
|
7
|
+
result = dc.compare("esquerda/", "direita.parquet", on=["id"], abs_tol={"valor": 0.005}, memory_limit="2GB")
|
|
8
|
+
result.is_match, result.rows.mismatched, result.columns["valor"].mismatches
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
|
|
15
|
+
# antes de qualquer import do Polars: o alocador dele lê a configuração uma vez só, quando carrega
|
|
16
|
+
from .core.allocator import configure as _configure_allocator
|
|
17
|
+
|
|
18
|
+
ALLOCATOR_STATUS = _configure_allocator()
|
|
19
|
+
|
|
20
|
+
from .api import compare # noqa: E402
|
|
21
|
+
from .core.memory import pool_limit, pool_stats, release_memory # noqa: E402
|
|
22
|
+
from .errors import AllocatorWarning, NativeLibraryError, UnsupportedTypeError # noqa: E402
|
|
23
|
+
from .options import CompareOptions # noqa: E402
|
|
24
|
+
from .results import ( # noqa: E402
|
|
25
|
+
ColumnResult,
|
|
26
|
+
ColumnSummary,
|
|
27
|
+
ComparisonResult,
|
|
28
|
+
ExecutionInfo,
|
|
29
|
+
RowSummary,
|
|
30
|
+
TypeMismatch,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
__version__ = metadata.version("nini-datacomp")
|
|
35
|
+
except metadata.PackageNotFoundError: # pragma: no cover (rodando de src/ sem instalar)
|
|
36
|
+
__version__ = "0+desconhecida"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ALLOCATOR_STATUS",
|
|
40
|
+
"AllocatorWarning",
|
|
41
|
+
"ColumnResult",
|
|
42
|
+
"ColumnSummary",
|
|
43
|
+
"CompareOptions",
|
|
44
|
+
"ComparisonResult",
|
|
45
|
+
"ExecutionInfo",
|
|
46
|
+
"NativeLibraryError",
|
|
47
|
+
"RowSummary",
|
|
48
|
+
"TypeMismatch",
|
|
49
|
+
"UnsupportedTypeError",
|
|
50
|
+
"__version__",
|
|
51
|
+
"compare",
|
|
52
|
+
"pool_limit",
|
|
53
|
+
"pool_stats",
|
|
54
|
+
"release_memory",
|
|
55
|
+
]
|
nini_datacomp/_native.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Carrega a biblioteca C++ (libdatacomp) via ctypes e aplica as assinaturas de `_signatures.py`.
|
|
2
|
+
|
|
3
|
+
Ordem de busca:
|
|
4
|
+
1. variável de ambiente ``NINI_DATACOMP_LIB`` (caminho do arquivo; útil para testar um build manual);
|
|
5
|
+
2. a pasta do pacote (instalação normal por wheel);
|
|
6
|
+
3. os arquivos registrados na instalação (instalação editável do ``uv sync``, em que o pacote aponta para ``src/`` e a
|
|
7
|
+
biblioteca compilada fica no site-packages).
|
|
8
|
+
|
|
9
|
+
O ctypes solta o GIL durante cada chamada, então os blocos rodam em paralelo de verdade numa ThreadPool.
|
|
10
|
+
Copiado do nini-datagen 0.3.0, sem a inicialização de tabelas (aqui não há tabelas a montar no carregamento).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ctypes
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
from importlib import metadata
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from ._signatures import ABI_VERSION, SIGNATURES
|
|
25
|
+
from .errors import NativeLibraryError
|
|
26
|
+
|
|
27
|
+
__all__ = ["ENV_VAR", "LIB_NAMES", "lib", "library_path", "ptr"]
|
|
28
|
+
|
|
29
|
+
ENV_VAR = "NINI_DATACOMP_LIB"
|
|
30
|
+
if sys.platform == "win32":
|
|
31
|
+
LIB_NAMES: tuple[str, ...] = ("datacomp.dll",)
|
|
32
|
+
elif sys.platform == "darwin":
|
|
33
|
+
LIB_NAMES = ("libdatacomp.dylib", "libdatacomp.so")
|
|
34
|
+
else:
|
|
35
|
+
LIB_NAMES = ("libdatacomp.so",)
|
|
36
|
+
|
|
37
|
+
_lock = threading.Lock()
|
|
38
|
+
_lib: ctypes.CDLL | None = None
|
|
39
|
+
_path: Path | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _candidates() -> list[Path]:
|
|
43
|
+
env = os.environ.get(ENV_VAR)
|
|
44
|
+
if env:
|
|
45
|
+
return [Path(env)] # pedido explícito: não procura em outro lugar
|
|
46
|
+
here = Path(__file__).resolve().parent
|
|
47
|
+
found = [here / name for name in LIB_NAMES]
|
|
48
|
+
try:
|
|
49
|
+
files = metadata.files("nini-datacomp") or []
|
|
50
|
+
except metadata.PackageNotFoundError:
|
|
51
|
+
files = []
|
|
52
|
+
found += [Path(str(f.locate())) for f in files if f.name in LIB_NAMES]
|
|
53
|
+
return found
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load(path: Path) -> ctypes.CDLL:
|
|
57
|
+
try:
|
|
58
|
+
handle = ctypes.CDLL(str(path))
|
|
59
|
+
except OSError as e:
|
|
60
|
+
raise NativeLibraryError(f"não consegui carregar {path}: {e}") from e
|
|
61
|
+
try:
|
|
62
|
+
version = handle.dc_abi_version()
|
|
63
|
+
except AttributeError as e:
|
|
64
|
+
raise NativeLibraryError(f"{path} não é a biblioteca do nini-datacomp (falta dc_abi_version)") from e
|
|
65
|
+
if version != ABI_VERSION:
|
|
66
|
+
raise NativeLibraryError(
|
|
67
|
+
f"{path} tem ABI {version}, mas o pacote Python espera {ABI_VERSION}: recompile"
|
|
68
|
+
" (uv sync --reinstall-package nini-datacomp) ou reinstale o pacote"
|
|
69
|
+
)
|
|
70
|
+
missing = [name for name in SIGNATURES if not hasattr(handle, name)]
|
|
71
|
+
if missing:
|
|
72
|
+
raise NativeLibraryError(f"{path} não exporta {missing}")
|
|
73
|
+
for name, (restype, argtypes) in SIGNATURES.items():
|
|
74
|
+
fn = getattr(handle, name)
|
|
75
|
+
fn.restype = restype
|
|
76
|
+
fn.argtypes = argtypes
|
|
77
|
+
return handle
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def lib() -> ctypes.CDLL:
|
|
81
|
+
"""A biblioteca carregada (carrega na primeira chamada; seguro entre threads)."""
|
|
82
|
+
global _lib, _path
|
|
83
|
+
if _lib is not None:
|
|
84
|
+
return _lib
|
|
85
|
+
with _lock:
|
|
86
|
+
if _lib is None:
|
|
87
|
+
tried = []
|
|
88
|
+
for path in _candidates():
|
|
89
|
+
if path.is_file():
|
|
90
|
+
_lib, _path = _load(path), path
|
|
91
|
+
break
|
|
92
|
+
tried.append(str(path))
|
|
93
|
+
else:
|
|
94
|
+
raise NativeLibraryError(
|
|
95
|
+
"biblioteca C++ do nini-datacomp não encontrada (procurei em: "
|
|
96
|
+
+ ", ".join(tried)
|
|
97
|
+
+ "). Numa cópia do repositório, rode `uv sync`; para um build manual, aponte "
|
|
98
|
+
+ f"{ENV_VAR} para o arquivo."
|
|
99
|
+
)
|
|
100
|
+
return _lib
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def library_path() -> Path:
|
|
104
|
+
"""Caminho do arquivo da biblioteca carregada."""
|
|
105
|
+
lib()
|
|
106
|
+
assert _path is not None
|
|
107
|
+
return _path
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _reset() -> None:
|
|
111
|
+
"""Só para testes: esquece a biblioteca carregada (a próxima chamada a `lib()` procura de novo)."""
|
|
112
|
+
global _lib, _path
|
|
113
|
+
with _lock:
|
|
114
|
+
_lib, _path = None, None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ptr(a: np.ndarray) -> int:
|
|
118
|
+
"""Endereço do primeiro elemento de um array contíguo (o C++ escreve/lê nele como um vetor plano)."""
|
|
119
|
+
if not a.flags.c_contiguous:
|
|
120
|
+
raise ValueError("o array precisa ser contíguo (C order) para ir ao C++")
|
|
121
|
+
return a.ctypes.data
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Tipos dos argumentos de cada função da biblioteca C++ (GERADO por tools/gen_bindings.py; não edite)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ctypes import c_double, c_float, c_int, c_int64, c_uint8, c_uint16, c_uint32, c_uint64, c_void_p
|
|
5
|
+
|
|
6
|
+
ABI_VERSION = 8
|
|
7
|
+
|
|
8
|
+
# nome: (tipo de retorno, [tipos dos argumentos])
|
|
9
|
+
SIGNATURES = {
|
|
10
|
+
"dc_abi_version": (c_int, []),
|
|
11
|
+
"dc_count_unset": (c_int64, [c_void_p, c_int64, c_int64]),
|
|
12
|
+
"dc_describe_batch": (c_int, [c_void_p, c_void_p, c_int, c_void_p]),
|
|
13
|
+
"dc_compare_column": (c_int64, [c_void_p, c_void_p, c_void_p, c_void_p, c_void_p, c_int64, c_int64, c_int, c_double, c_double, c_double, c_void_p]),
|
|
14
|
+
"dc_compare_rows": (c_int, [c_int, c_void_p, c_void_p, c_int64, c_int64, c_int64, c_void_p]),
|
|
15
|
+
"dc_compare_pairs": (c_int, [c_int, c_int, c_void_p, c_void_p, c_int, c_void_p, c_void_p, c_void_p, c_void_p, c_void_p, c_int64, c_int64, c_int64, c_void_p]),
|
|
16
|
+
"dc_align_keys": (c_int, [c_int, c_void_p, c_int64, c_void_p, c_int64, c_int, c_void_p, c_void_p, c_void_p]),
|
|
17
|
+
"dc_pool_alloc": (c_int64, [c_int64]),
|
|
18
|
+
"dc_pool_free": (None, [c_int64]),
|
|
19
|
+
"dc_pool_limit": (c_int64, [c_int64]),
|
|
20
|
+
"dc_pool_trim": (None, []),
|
|
21
|
+
"dc_pool_stats": (None, [c_void_p]),
|
|
22
|
+
"dc_parquet_row_groups": (c_int64, [c_void_p, c_int64, c_void_p, c_int64]),
|
|
23
|
+
"dc_row_hash": (c_int, [c_int, c_void_p, c_int64, c_int64, c_int, c_void_p]),
|
|
24
|
+
"dc_diff_pairs": (c_int64, [c_int, c_void_p, c_void_p, c_void_p, c_void_p, c_void_p, c_void_p, c_int64, c_int64, c_int, c_void_p, c_void_p]),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
__all__ = ['ABI_VERSION', 'SIGNATURES', 'c_double', 'c_float', 'c_int', 'c_int64', 'c_uint8', 'c_uint16', 'c_uint32', 'c_uint64', 'c_void_p']
|
nini_datacomp/api.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""`compare`: a função pública.
|
|
2
|
+
|
|
3
|
+
Abre as fontes, ajusta os nomes das colunas, confere a chave (os erros fatais vão para ``result.errors``, como no
|
|
4
|
+
datacompolars) e roda o motor (`pipeline._run`): janelas que cabem no ``memory_limit``, hash de linha nas colunas
|
|
5
|
+
exatas, coluna a coluna nas com tolerância, contagens por coluna e amostras.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
from .io import Source, SourceLike, open_source
|
|
17
|
+
from .options import CompareOptions
|
|
18
|
+
from .pipeline import RowCounts, _run, _tol_of, align_columns, sample_key_names
|
|
19
|
+
from .results import ColumnResult, ColumnSummary, ComparisonResult, ExecutionInfo, RowSummary, TypeMismatch
|
|
20
|
+
|
|
21
|
+
__all__ = ["compare"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compare(
|
|
25
|
+
left: SourceLike,
|
|
26
|
+
right: SourceLike,
|
|
27
|
+
on: str | Sequence[str],
|
|
28
|
+
*,
|
|
29
|
+
options: CompareOptions | None = None,
|
|
30
|
+
**kwargs: Any,
|
|
31
|
+
) -> ComparisonResult:
|
|
32
|
+
"""Compara ``left`` e ``right`` (caminho de Parquet, pasta, glob, ``pl.DataFrame`` ou ``pl.LazyFrame``) pela chave
|
|
33
|
+
``on`` (uma coluna ou várias).
|
|
34
|
+
|
|
35
|
+
As opções vêm em ``options`` (`CompareOptions`) ou como argumentos nomeados (``abs_tol=0.01``); com os dois, os
|
|
36
|
+
argumentos nomeados ganham. Erros fatais (chave que falta, de tipos diferentes, repetida) não levantam exceção:
|
|
37
|
+
ficam em ``result.errors``, com ``result.aborted`` verdadeiro. Tipos que o pacote não compara (List, Struct)
|
|
38
|
+
levantam `UnsupportedTypeError`.
|
|
39
|
+
"""
|
|
40
|
+
t0 = time.perf_counter()
|
|
41
|
+
if kwargs:
|
|
42
|
+
base = options.model_dump() if options is not None else {}
|
|
43
|
+
options = CompareOptions(**{**base, **kwargs})
|
|
44
|
+
opts = options if options is not None else CompareOptions()
|
|
45
|
+
lower = not opts.case_sensitive_columns
|
|
46
|
+
keys = _keys(on, lower=lower)
|
|
47
|
+
abs_tol, rel_tol = _names_of(opts.abs_tol, lower), _names_of(opts.rel_tol, lower)
|
|
48
|
+
|
|
49
|
+
ls, rs = open_source(left), open_source(right)
|
|
50
|
+
errors: list[str] = []
|
|
51
|
+
if lower:
|
|
52
|
+
ls, rs = _lowercase(ls, "left", errors), _lowercase(rs, "right", errors)
|
|
53
|
+
if errors:
|
|
54
|
+
return _failed(opts, keys, errors, left_only=(), right_only=(), t0=t0)
|
|
55
|
+
lschema, rschema = ls.lazy.collect_schema(), rs.lazy.collect_schema()
|
|
56
|
+
left_only = tuple(c for c in lschema.names() if c not in rschema)
|
|
57
|
+
right_only = tuple(c for c in rschema.names() if c not in lschema)
|
|
58
|
+
errors += _key_errors(lschema, rschema, keys, opts.cast)
|
|
59
|
+
if errors:
|
|
60
|
+
return _failed(opts, keys, errors, left_only=left_only, right_only=right_only, t0=t0)
|
|
61
|
+
|
|
62
|
+
cols = align_columns(ls, rs, keys, opts.cast) # schemas só; UnsupportedTypeError para aninhadas
|
|
63
|
+
warnings: list[str] = []
|
|
64
|
+
if not cols.compared:
|
|
65
|
+
warnings.append("No common columns besides the join key: only row membership was compared.")
|
|
66
|
+
for label, tol in (("abs_tol", abs_tol), ("rel_tol", rel_tol)):
|
|
67
|
+
if isinstance(tol, dict) and (unknown := sorted(set(tol) - set(cols.compared))):
|
|
68
|
+
warnings.append(f"{label} names columns that are not compared: {unknown}")
|
|
69
|
+
|
|
70
|
+
res = _run(
|
|
71
|
+
left, right, keys, memory_limit=opts.memory_limit, window_rows=opts.window_rows, columns=None, prefetch=True,
|
|
72
|
+
engine="auto", hashing=True, casting=opts.cast, verify_hash=opts.verify_hash, threads=opts.threads,
|
|
73
|
+
abs_tol=abs_tol, rel_tol=rel_tol, ignore_case=opts.ignore_case, ignore_spaces=opts.ignore_whitespace,
|
|
74
|
+
details=opts.details, samples=opts.samples, sources=(ls, rs),
|
|
75
|
+
) # fmt: skip
|
|
76
|
+
if res.dup_left:
|
|
77
|
+
errors.append(f"Duplicate join keys in the left table: {res.dup_left:,} surplus rows")
|
|
78
|
+
if res.dup_right:
|
|
79
|
+
errors.append(f"Duplicate join keys in the right table: {res.dup_right:,} surplus rows")
|
|
80
|
+
|
|
81
|
+
dtypes = {c: cols.left_casts.get(c, lschema[c]) for c in lschema.names()}
|
|
82
|
+
key_dtypes = [_sample_dtype(dtypes[k]) for k in keys]
|
|
83
|
+
details = opts.details and not errors
|
|
84
|
+
results = {}
|
|
85
|
+
for c in res.compared_columns:
|
|
86
|
+
n = res.column_mismatches.get(c, 0) if details else None
|
|
87
|
+
tol = (_tol_of(abs_tol, c), _tol_of(rel_tol, c)) if c in res.tolerance_columns else None
|
|
88
|
+
results[c] = ColumnResult(
|
|
89
|
+
name=c,
|
|
90
|
+
dtype=str(dtypes[c]),
|
|
91
|
+
mismatches=n,
|
|
92
|
+
rate=None if n is None else (n / res.common if res.common else 0.0),
|
|
93
|
+
samples=_samples(
|
|
94
|
+
res, c, keys=keys, key_dtypes=key_dtypes, dtype=_sample_dtype(dtypes[c]), names=opts.names
|
|
95
|
+
),
|
|
96
|
+
tolerance=tol,
|
|
97
|
+
)
|
|
98
|
+
columns = ColumnSummary(
|
|
99
|
+
join_columns=tuple(keys),
|
|
100
|
+
results=results,
|
|
101
|
+
left_only=left_only,
|
|
102
|
+
right_only=right_only,
|
|
103
|
+
type_mismatches=tuple(TypeMismatch(c, a, b) for c, a, b in res.type_mismatches),
|
|
104
|
+
)
|
|
105
|
+
return ComparisonResult(
|
|
106
|
+
names=opts.names,
|
|
107
|
+
join_columns=tuple(keys),
|
|
108
|
+
rows=_rows(res, aborted=bool(errors)),
|
|
109
|
+
columns=columns,
|
|
110
|
+
errors=tuple(errors),
|
|
111
|
+
warnings=tuple(warnings),
|
|
112
|
+
execution=_execution(res, opts, details=details, t0=t0),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ----------------------------------------------------------------------------- nomes e chave
|
|
117
|
+
def _keys(on: str | Sequence[str], *, lower: bool) -> list[str]:
|
|
118
|
+
keys = [on] if isinstance(on, str) else list(on)
|
|
119
|
+
if not keys:
|
|
120
|
+
raise ValueError("on precisa de ao menos uma coluna")
|
|
121
|
+
if any(not isinstance(k, str) or not k.strip() for k in keys):
|
|
122
|
+
raise ValueError("on não pode ter nomes vazios")
|
|
123
|
+
if lower:
|
|
124
|
+
keys = [k.lower() for k in keys]
|
|
125
|
+
if len(set(keys)) != len(keys):
|
|
126
|
+
raise ValueError(f"on tem colunas repetidas: {keys}")
|
|
127
|
+
return keys
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _names_of(tol: float | dict[str, float], lower: bool) -> float | dict[str, float]:
|
|
131
|
+
if isinstance(tol, dict) and lower:
|
|
132
|
+
return {k.lower(): v for k, v in tol.items()}
|
|
133
|
+
return tol
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _lowercase(src: Source, side: str, errors: list[str]) -> Source:
|
|
137
|
+
"""Os nomes das colunas em minúsculas; dois que viram o mesmo são erro fatal (como no datacompolars), e a fonte
|
|
138
|
+
volta como estava (renomear com a colisão quebraria no Polars)."""
|
|
139
|
+
names = src.lazy.collect_schema().names()
|
|
140
|
+
seen: set[str] = set()
|
|
141
|
+
clash = False
|
|
142
|
+
for n in names:
|
|
143
|
+
low = n.lower()
|
|
144
|
+
if low in seen:
|
|
145
|
+
errors.append(f"Columns collide in the {side} table after lowercasing names: '{low}'")
|
|
146
|
+
clash = True
|
|
147
|
+
seen.add(low)
|
|
148
|
+
return src if clash else src.rename({n: n.lower() for n in names if n != n.lower()})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _key_errors(lschema: pl.Schema, rschema: pl.Schema, keys: Sequence[str], cast: str) -> list[str]:
|
|
152
|
+
errors = []
|
|
153
|
+
for side, schema in (("left", lschema), ("right", rschema)):
|
|
154
|
+
missing = [k for k in keys if k not in schema]
|
|
155
|
+
if missing:
|
|
156
|
+
errors.append(f"Join keys missing from the {side} table: {missing}")
|
|
157
|
+
if errors or cast != "none":
|
|
158
|
+
return errors
|
|
159
|
+
return [
|
|
160
|
+
f"Join key '{k}' has different types: left {lschema[k]} vs right {rschema[k]}"
|
|
161
|
+
for k in keys
|
|
162
|
+
if lschema[k] != rschema[k]
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ----------------------------------------------------------------------------- resultado
|
|
167
|
+
def _sample_dtype(dtype: pl.DataType) -> pl.DataType:
|
|
168
|
+
"""O tipo da coluna nas amostras: Categorical e Enum são comparados (e mostrados) como texto."""
|
|
169
|
+
return pl.String() if isinstance(dtype, (pl.Categorical, pl.Enum)) else dtype
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _samples(
|
|
173
|
+
res: RowCounts,
|
|
174
|
+
column: str,
|
|
175
|
+
*,
|
|
176
|
+
keys: Sequence[str],
|
|
177
|
+
key_dtypes: Sequence[pl.DataType],
|
|
178
|
+
dtype: pl.DataType,
|
|
179
|
+
names: tuple[str, str],
|
|
180
|
+
) -> pl.DataFrame:
|
|
181
|
+
headers = [*keys, f"{column} ({names[0]})", f"{column} ({names[1]})"]
|
|
182
|
+
frame = res.samples.get(column)
|
|
183
|
+
if frame is None:
|
|
184
|
+
return pl.DataFrame(schema=dict(zip(headers, [*key_dtypes, dtype, dtype], strict=True)))
|
|
185
|
+
raw = [*sample_key_names(len(keys)), "__left", "__right"]
|
|
186
|
+
return frame.rename(dict(zip(raw, headers, strict=True)))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _rows(res: RowCounts, *, aborted: bool) -> RowSummary:
|
|
190
|
+
if aborted: # como no datacompolars: nada contado
|
|
191
|
+
return RowSummary(0, 0, 0, 0, 0, 0)
|
|
192
|
+
return RowSummary(
|
|
193
|
+
left_total=res.left_total,
|
|
194
|
+
right_total=res.right_total,
|
|
195
|
+
common=res.common,
|
|
196
|
+
left_only=res.left_only,
|
|
197
|
+
right_only=res.right_only,
|
|
198
|
+
mismatched=res.mismatched or 0,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _versions() -> tuple[str, str]:
|
|
203
|
+
from . import __version__ # noqa: PLC0415 - o pacote já carregou quando compare roda
|
|
204
|
+
|
|
205
|
+
return __version__, pl.__version__
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _execution(res: RowCounts, opts: CompareOptions, *, details: bool, t0: float) -> ExecutionInfo:
|
|
209
|
+
version, polars_version = _versions()
|
|
210
|
+
return ExecutionInfo(
|
|
211
|
+
windows=res.windows,
|
|
212
|
+
rows_per_window=res.rows_per_window,
|
|
213
|
+
column_groups=res.column_groups,
|
|
214
|
+
memory_limit=res.memory_limit,
|
|
215
|
+
window_bytes=res.window_bytes,
|
|
216
|
+
tolerance_columns=res.tolerance_columns,
|
|
217
|
+
verify_hash=opts.verify_hash,
|
|
218
|
+
hash_collisions=res.hash_collisions,
|
|
219
|
+
details_computed=details,
|
|
220
|
+
version=version,
|
|
221
|
+
polars_version=polars_version,
|
|
222
|
+
total_seconds=round(time.perf_counter() - t0, 6),
|
|
223
|
+
timings=dict(res.timings),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _failed(
|
|
228
|
+
opts: CompareOptions,
|
|
229
|
+
keys: Sequence[str],
|
|
230
|
+
errors: Sequence[str],
|
|
231
|
+
*,
|
|
232
|
+
left_only: tuple[str, ...],
|
|
233
|
+
right_only: tuple[str, ...],
|
|
234
|
+
t0: float,
|
|
235
|
+
) -> ComparisonResult:
|
|
236
|
+
"""O resultado de uma comparação que não começou (erro fatal antes de ler os dados)."""
|
|
237
|
+
version, polars_version = _versions()
|
|
238
|
+
return ComparisonResult(
|
|
239
|
+
names=opts.names,
|
|
240
|
+
join_columns=tuple(keys),
|
|
241
|
+
rows=RowSummary(0, 0, 0, 0, 0, 0),
|
|
242
|
+
columns=ColumnSummary(join_columns=tuple(keys), results={}, left_only=left_only, right_only=right_only),
|
|
243
|
+
errors=tuple(errors),
|
|
244
|
+
execution=ExecutionInfo(
|
|
245
|
+
verify_hash=opts.verify_hash,
|
|
246
|
+
version=version,
|
|
247
|
+
polars_version=polars_version,
|
|
248
|
+
total_seconds=round(time.perf_counter() - t0, 6),
|
|
249
|
+
),
|
|
250
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Infraestrutura interna: threads (`blocks`), memória (`memory`) e a interface C do Arrow (`arrow_c`, `arrow`)."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""O alocador do Polars: no Windows ele é o mimalloc, que por padrão guarda a memória liberada em vez de devolvê-la.
|
|
2
|
+
|
|
3
|
+
Medido na fase 1c (``tools/diag_memory.py``, 10M linhas, ``memory_limit = 1GB``): com as janelas de ~200 MB, o
|
|
4
|
+
processo ia acumulando memória janela após janela e terminava com ~1,1 GB, quase toda já liberada pelo Polars. Com
|
|
5
|
+
``MIMALLOC_PURGE_DELAY=0`` o mimalloc devolve a memória na hora: pico de ~0,6–0,7 GB (−40 a −48%), sem custo de tempo
|
|
6
|
+
nas tabelas largas (10M x 50: 5,6 s → 4,2 s) e +0,3 s nas estreitas (10M x 10).
|
|
7
|
+
|
|
8
|
+
O mimalloc lê a variável uma vez, quando a biblioteca do Polars carrega; depois não há como mudar. Por isso o
|
|
9
|
+
``nini_datacomp`` a define no próprio import, antes de importar o Polars, e só se o Polars ainda não estiver carregado
|
|
10
|
+
(e sem sobrescrever um valor que já exista). Se o Polars veio antes, avisa com `AllocatorWarning`.
|
|
11
|
+
|
|
12
|
+
``NINI_DATACOMP_ALLOCATOR=0`` desliga tudo isso (nem define a variável, nem avisa).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import warnings
|
|
20
|
+
from collections.abc import Container, MutableMapping
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
from ..errors import AllocatorWarning
|
|
24
|
+
|
|
25
|
+
__all__ = ["ALLOCATOR_ENV", "SETTINGS", "Status", "configure"]
|
|
26
|
+
|
|
27
|
+
ALLOCATOR_ENV = "NINI_DATACOMP_ALLOCATOR"
|
|
28
|
+
SETTINGS = {"MIMALLOC_PURGE_DELAY": "0", "MIMALLOC_RESET_DELAY": "0"} # RESET_DELAY: o nome antigo, mimalloc < 2.1
|
|
29
|
+
|
|
30
|
+
# "set": definida agora; "user": já estava definida; "late": o Polars já tinha carregado; "disabled": desligado
|
|
31
|
+
Status = Literal["set", "user", "late", "disabled"]
|
|
32
|
+
|
|
33
|
+
_LATE = (
|
|
34
|
+
"o Polars foi importado antes do nini_datacomp, então o alocador dele (mimalloc) vai guardar a memória que libera "
|
|
35
|
+
"e o pico de uma comparação grande pode ficar bem maior (medido: até ~1,9x). Para evitar, importe o nini_datacomp "
|
|
36
|
+
"antes do Polars ou defina MIMALLOC_PURGE_DELAY=0 antes de iniciar o Python. NINI_DATACOMP_ALLOCATOR=0 silencia "
|
|
37
|
+
"este aviso."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def configure(
|
|
42
|
+
environ: MutableMapping[str, str] | None = None,
|
|
43
|
+
modules: Container[str] | None = None,
|
|
44
|
+
platform: str | None = None,
|
|
45
|
+
) -> Status:
|
|
46
|
+
"""Define as variáveis do mimalloc se ainda der tempo; avisa (só no Windows, onde foi medido) se não der."""
|
|
47
|
+
environ = os.environ if environ is None else environ
|
|
48
|
+
modules = sys.modules if modules is None else modules
|
|
49
|
+
platform = sys.platform if platform is None else platform
|
|
50
|
+
if environ.get(ALLOCATOR_ENV, "1") == "0":
|
|
51
|
+
return "disabled"
|
|
52
|
+
if "MIMALLOC_PURGE_DELAY" in environ:
|
|
53
|
+
return "user"
|
|
54
|
+
if "polars" in modules:
|
|
55
|
+
if platform == "win32":
|
|
56
|
+
warnings.warn(_LATE, AllocatorWarning, stacklevel=2)
|
|
57
|
+
return "late"
|
|
58
|
+
for name, value in SETTINGS.items():
|
|
59
|
+
environ.setdefault(name, value)
|
|
60
|
+
return "set"
|