padmy 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- padmy-0.4.0/PKG-INFO +19 -0
- padmy-0.4.0/padmy/__init__.py +0 -0
- padmy-0.4.0/padmy/anonymize/__init__.py +1 -0
- padmy-0.4.0/padmy/anonymize/anonymize.py +102 -0
- padmy-0.4.0/padmy/config.py +113 -0
- padmy-0.4.0/padmy/db.py +307 -0
- padmy-0.4.0/padmy/env.py +11 -0
- padmy-0.4.0/padmy/logs.py +13 -0
- padmy-0.4.0/padmy/migration/__init__.py +3 -0
- padmy-0.4.0/padmy/migration/create_files.py +57 -0
- padmy-0.4.0/padmy/migration/db.sql +23 -0
- padmy-0.4.0/padmy/migration/migration.py +244 -0
- padmy-0.4.0/padmy/migration/new_sql.py +36 -0
- padmy-0.4.0/padmy/migration/run.py +130 -0
- padmy-0.4.0/padmy/migration/utils.py +47 -0
- padmy-0.4.0/padmy/sampling/__init__.py +1 -0
- padmy-0.4.0/padmy/sampling/network.py +36 -0
- padmy-0.4.0/padmy/sampling/sampling.py +236 -0
- padmy-0.4.0/padmy/sampling/viz.py +165 -0
- padmy-0.4.0/padmy/utils.py +220 -0
- padmy-0.4.0/pyproject.toml +86 -0
- padmy-0.4.0/setup.py +44 -0
padmy-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: padmy
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: andarius
|
|
6
|
+
Author-email: julien.brayere@tracktor.fr
|
|
7
|
+
Requires-Python: >=3.10,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Provides-Extra: network
|
|
12
|
+
Requires-Dist: Faker (>=13.15.1,<14.0.0)
|
|
13
|
+
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
14
|
+
Requires-Dist: asyncpg (>=0.27.0,<0.28.0)
|
|
15
|
+
Requires-Dist: dash (>=2.6.0,<3.0.0); extra == "network"
|
|
16
|
+
Requires-Dist: dash-cytoscape (>=0.3.0,<0.4.0); extra == "network"
|
|
17
|
+
Requires-Dist: networkx (>=2.8.5,<3.0.0); extra == "network"
|
|
18
|
+
Requires-Dist: piou (>=0.13.1,<0.14.0)
|
|
19
|
+
Requires-Dist: typing-extensions (>=4.3.0,<5.0.0)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .anonymize import anonymize_db
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import itertools
|
|
3
|
+
import logging
|
|
4
|
+
import operator
|
|
5
|
+
from functools import partial
|
|
6
|
+
from typing import Any, Iterator
|
|
7
|
+
|
|
8
|
+
import asyncpg
|
|
9
|
+
from faker import Faker
|
|
10
|
+
|
|
11
|
+
from ..config import Config, ConfigTable, FieldType, AnoFields
|
|
12
|
+
from ..db import load_primary_keys, load_columns_type
|
|
13
|
+
from ..utils import get_conn, iterate_pg
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_update_query(table: str, pks: list[str], fields: list[str],
|
|
17
|
+
field_types: dict):
|
|
18
|
+
_table_keys = pks + fields
|
|
19
|
+
_set_fields = ', '.join(f'{_field} = u2.{_field}'
|
|
20
|
+
for _field in fields)
|
|
21
|
+
_values = ', '.join(f'${i + 1}::{field_types[k]}' for i, k in enumerate(_table_keys))
|
|
22
|
+
_where = ' and '.join(f'u2.{_pk} = u.{_pk}' for _pk in pks)
|
|
23
|
+
|
|
24
|
+
query = f"""
|
|
25
|
+
UPDATE {table} as u
|
|
26
|
+
SET
|
|
27
|
+
{_set_fields}
|
|
28
|
+
from (values
|
|
29
|
+
({_values})
|
|
30
|
+
) as u2({', '.join(_table_keys)})
|
|
31
|
+
where {_where}
|
|
32
|
+
"""
|
|
33
|
+
return query
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_fake_value(faker: Faker, field: FieldType,
|
|
37
|
+
extra_fields: dict | None = None) -> Any:
|
|
38
|
+
_extra_fields = extra_fields or {}
|
|
39
|
+
match field:
|
|
40
|
+
case 'EMAIL':
|
|
41
|
+
return faker.email(**_extra_fields)
|
|
42
|
+
case _:
|
|
43
|
+
raise ValueError(f'Got unimplemented field type {field!r}')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def gen_mock_data(faker: Faker,
|
|
47
|
+
fields: list[AnoFields],
|
|
48
|
+
size: int) -> Iterator[dict]:
|
|
49
|
+
for _ in range(size):
|
|
50
|
+
yield {
|
|
51
|
+
v.column: _get_fake_value(faker, v.type, v.extra_args) for v in fields
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def dict_to_tuple(d: dict, fields: list[str]) -> tuple:
|
|
56
|
+
return tuple(d[k] for k in fields)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def anonymize_table(conn: asyncpg.Connection,
|
|
60
|
+
table: ConfigTable,
|
|
61
|
+
pks: list[str],
|
|
62
|
+
faker: Faker,
|
|
63
|
+
*,
|
|
64
|
+
chunk_size: int = 1_000):
|
|
65
|
+
if table.fields is None:
|
|
66
|
+
raise ValueError('Fields must not be empty')
|
|
67
|
+
if not pks:
|
|
68
|
+
raise ValueError(f'No PKs found for {table.full_name!r}')
|
|
69
|
+
|
|
70
|
+
query = f"SELECT {', '.join(pks)} from {table.schema}.{table.table}"
|
|
71
|
+
|
|
72
|
+
fields = [x.column for x in table.fields]
|
|
73
|
+
fields_types = await load_columns_type(conn, table.schema, table.table,
|
|
74
|
+
pks + fields)
|
|
75
|
+
update_query = get_update_query(table.full_name, pks, fields,
|
|
76
|
+
fields_types)
|
|
77
|
+
|
|
78
|
+
async with conn.transaction():
|
|
79
|
+
async for chunk in iterate_pg(conn, query, chunk_size=chunk_size):
|
|
80
|
+
mock_data = gen_mock_data(faker, fields=table.fields, size=chunk_size)
|
|
81
|
+
new_data = [dict_to_tuple({**c, **m}, pks + fields) for c, m in zip(chunk, mock_data)]
|
|
82
|
+
await conn.executemany(update_query, new_data)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def anonymize_db(pool: asyncpg.Pool, config: Config, faker: Faker):
|
|
86
|
+
_tables_to_anonymize = [_table for _table in config.tables if _table.has_ano_fields]
|
|
87
|
+
|
|
88
|
+
if not _tables_to_anonymize:
|
|
89
|
+
logging.info('No tables found to anonymize in config file')
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
async with pool.acquire() as conn:
|
|
93
|
+
pks = await load_primary_keys(conn, list({_table.schema for _table in _tables_to_anonymize}))
|
|
94
|
+
|
|
95
|
+
_pks = {_table_name: list(_table_pks) for _table_name, _table_pks in
|
|
96
|
+
itertools.groupby(pks, operator.attrgetter('full_name'))}
|
|
97
|
+
|
|
98
|
+
await asyncio.gather(*[get_conn(pool, partial(anonymize_table,
|
|
99
|
+
table=_table,
|
|
100
|
+
pks=[x.column_name for x in _pks[_table.full_name]],
|
|
101
|
+
faker=faker))
|
|
102
|
+
for _table in _tables_to_anonymize])
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
# else:
|
|
4
|
+
# from typing import Self
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
# if sys.version_info.minor < 11 and sys.version_info.major >= 3:
|
|
10
|
+
|
|
11
|
+
FieldType = Literal['EMAIL']
|
|
12
|
+
|
|
13
|
+
SampleType = float | int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check_sample_size(sample: SampleType | None):
|
|
17
|
+
if sample is None:
|
|
18
|
+
return
|
|
19
|
+
if sample < 0 or sample > 100:
|
|
20
|
+
raise ValueError(f'Sample must be a value between 0 and 100 (got {sample})')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AnoFields:
|
|
25
|
+
column: str
|
|
26
|
+
type: FieldType
|
|
27
|
+
extra_args: dict | None = None
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def load(cls, data: dict):
|
|
31
|
+
|
|
32
|
+
if len(data) == 1:
|
|
33
|
+
column = next(iter(data))
|
|
34
|
+
_type = data[column]
|
|
35
|
+
extra_args = None
|
|
36
|
+
else:
|
|
37
|
+
column = data.pop('column')
|
|
38
|
+
_type = data.pop('type')
|
|
39
|
+
extra_args = data if data else None
|
|
40
|
+
|
|
41
|
+
return AnoFields(column=column,
|
|
42
|
+
type=_type,
|
|
43
|
+
extra_args=extra_args)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ConfigTable:
|
|
48
|
+
schema: str
|
|
49
|
+
table: str
|
|
50
|
+
sample: SampleType | None = None
|
|
51
|
+
|
|
52
|
+
fields: list[AnoFields] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
ignore: bool = False
|
|
55
|
+
|
|
56
|
+
def __post_init__(self):
|
|
57
|
+
_check_sample_size(self.sample)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def full_name(self):
|
|
61
|
+
return f'{self.schema}.{self.table}'
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def has_ano_fields(self):
|
|
65
|
+
return self.fields is not None
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def load(cls, table: dict):
|
|
69
|
+
_fields_any: dict | list = table.pop('fields', [])
|
|
70
|
+
_fields: list = [_fields_any] if isinstance(_fields_any, dict) else _fields_any
|
|
71
|
+
|
|
72
|
+
return cls(**table,
|
|
73
|
+
fields=[AnoFields.load(_field) for _field in _fields])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ConfigSchema:
|
|
78
|
+
schema: str
|
|
79
|
+
sample: SampleType | None = None
|
|
80
|
+
|
|
81
|
+
def __post_init__(self):
|
|
82
|
+
_check_sample_size(self.sample)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def load(cls, v: str | dict):
|
|
86
|
+
if isinstance(v, str):
|
|
87
|
+
return cls(v)
|
|
88
|
+
else:
|
|
89
|
+
return cls(v['name'], v.get('sample'))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Config:
|
|
94
|
+
sample: SampleType | None = None
|
|
95
|
+
schemas: list[ConfigSchema] = field(default_factory=list)
|
|
96
|
+
tables: list[ConfigTable] = field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
def __post_init__(self):
|
|
99
|
+
_check_sample_size(self.sample)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def load(cls, sample: SampleType, schemas: list[str]):
|
|
103
|
+
_schemas = [ConfigSchema(x) for x in schemas]
|
|
104
|
+
return cls(sample=sample, schemas=_schemas)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def load_from_file(cls, path: Path):
|
|
108
|
+
with path.open('r') as f:
|
|
109
|
+
config = yaml.load(f, Loader=yaml.Loader)
|
|
110
|
+
schemas = [ConfigSchema.load(schema) for schema in config.pop('schemas', [])]
|
|
111
|
+
tables = [ConfigTable.load(table) for table in config.pop('tables', [])]
|
|
112
|
+
|
|
113
|
+
return cls(**config, schemas=schemas, tables=tables)
|
padmy-0.4.0/padmy/db.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
import asyncpg
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table as RTable
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from padmy.config import Config, SampleType
|
|
11
|
+
from padmy.utils import get_first, get_conn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# if sys.version_info.minor < 11 and sys.version_info.major >= 3:
|
|
15
|
+
# else:
|
|
16
|
+
# from typing import Self
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_full_name(schema: str | None, table: str | None) -> str:
|
|
20
|
+
if schema is None or table is None:
|
|
21
|
+
raise ValueError('schema and table must not be empty')
|
|
22
|
+
return f'{schema}.{table}'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FKConstraint:
|
|
27
|
+
column_name: str
|
|
28
|
+
constraint_name: str
|
|
29
|
+
|
|
30
|
+
# references
|
|
31
|
+
foreign_schema: str
|
|
32
|
+
foreign_table: str
|
|
33
|
+
foreign_column_name: str
|
|
34
|
+
|
|
35
|
+
table: str | None = None
|
|
36
|
+
schema: str | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def foreign_full_name(self):
|
|
40
|
+
return _get_full_name(self.foreign_schema, self.foreign_table)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def full_name(self):
|
|
44
|
+
return _get_full_name(self.schema, self.table)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class PKConstraint:
|
|
49
|
+
column_name: str
|
|
50
|
+
table: str
|
|
51
|
+
schema: str
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def full_name(self):
|
|
55
|
+
return _get_full_name(self.schema, self.table)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(eq=False)
|
|
59
|
+
class Table:
|
|
60
|
+
schema: str
|
|
61
|
+
table: str
|
|
62
|
+
|
|
63
|
+
_count: int | None = None # field(init=False)
|
|
64
|
+
|
|
65
|
+
foreign_keys: list[FKConstraint] = field(default_factory=list)
|
|
66
|
+
primary_keys: list[PKConstraint] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
parent_tables: set[Self] = field(default_factory=set)
|
|
69
|
+
child_tables: set[Self] = field(default_factory=set)
|
|
70
|
+
|
|
71
|
+
# Has already been sampled and it's temporary table has been created
|
|
72
|
+
has_been_processed: bool = False
|
|
73
|
+
|
|
74
|
+
# Sample size
|
|
75
|
+
sample_size: int | None = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def parent_tables_safe(self):
|
|
79
|
+
# return self.parent_tables - {self}
|
|
80
|
+
return {x for x in self.parent_tables if x.full_name != self.full_name}
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def child_tables_safe(self):
|
|
84
|
+
# return self.child_tables - {self}
|
|
85
|
+
return {x for x in self.child_tables if x.full_name != self.full_name}
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def full_name(self):
|
|
89
|
+
return _get_full_name(self.schema, self.table)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def tmp_name(self):
|
|
93
|
+
return f'_{self.schema}_{self.table}_tmp'
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def has_parent(self):
|
|
97
|
+
return len(self.parent_tables_safe) > 0
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def has_children(self):
|
|
101
|
+
return len(self.child_tables_safe) > 0
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def children_has_been_processed(self):
|
|
105
|
+
return all(_child.has_been_processed for _child in self.child_tables_safe)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def count(self) -> int:
|
|
109
|
+
if self._count is None:
|
|
110
|
+
raise ValueError('Count must be loaded first')
|
|
111
|
+
return self._count
|
|
112
|
+
|
|
113
|
+
@count.setter
|
|
114
|
+
def count(self, v: int):
|
|
115
|
+
self._count = v
|
|
116
|
+
|
|
117
|
+
async def load_count(self, conn: asyncpg.Connection):
|
|
118
|
+
self._count = await conn.fetchval(f'SELECT count(*) from {self.full_name}')
|
|
119
|
+
|
|
120
|
+
def __eq__(self, other: Self):
|
|
121
|
+
for k in ['full_name', 'has_been_processed']:
|
|
122
|
+
if getattr(self, k) != getattr(other, k):
|
|
123
|
+
return False
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def __hash__(self):
|
|
127
|
+
return hash((getattr(self, x) for x in ['full_name']))
|
|
128
|
+
|
|
129
|
+
def __repr__(self):
|
|
130
|
+
return f'Table(full_name={self.full_name!r} ' \
|
|
131
|
+
f'count={self._count} ' \
|
|
132
|
+
f'foreign_keys={len(self.foreign_keys)} ' \
|
|
133
|
+
f'parents={len(self.parent_tables)} ' \
|
|
134
|
+
f'children={len(self.child_tables)}' \
|
|
135
|
+
f')'
|
|
136
|
+
|
|
137
|
+
def __post_init__(self):
|
|
138
|
+
for fk in self.foreign_keys:
|
|
139
|
+
fk.table = self.table
|
|
140
|
+
fk.schema = self.schema
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def get_tables(conn: asyncpg.Connection, schemas: list[str]):
|
|
144
|
+
query = """
|
|
145
|
+
select
|
|
146
|
+
table_schema as schema, table_name as table
|
|
147
|
+
from information_schema.tables
|
|
148
|
+
where table_schema = ANY ($1::text[]) and
|
|
149
|
+
table_type = 'BASE TABLE'
|
|
150
|
+
"""
|
|
151
|
+
data = await conn.fetch(query, schemas)
|
|
152
|
+
return [Table(**x) for x in data]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
SCHEMA_FK_QUERY = """
|
|
156
|
+
SELECT
|
|
157
|
+
tc.table_schema as schema,
|
|
158
|
+
tc.constraint_name,
|
|
159
|
+
tc.table_name as table,
|
|
160
|
+
kcu.column_name,
|
|
161
|
+
ccu.table_schema AS foreign_schema,
|
|
162
|
+
ccu.table_name AS foreign_table,
|
|
163
|
+
ccu.column_name AS foreign_column_name
|
|
164
|
+
FROM
|
|
165
|
+
information_schema.table_constraints AS tc
|
|
166
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
167
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
168
|
+
AND tc.table_schema = kcu.table_schema
|
|
169
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
170
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
171
|
+
AND ccu.table_schema = tc.table_schema
|
|
172
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = ANY ($1::text[]);
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
SCHEMA_PK_QUERY = """
|
|
176
|
+
SELECT
|
|
177
|
+
tc.table_schema as schema,
|
|
178
|
+
tc.table_name as table,
|
|
179
|
+
c.column_name
|
|
180
|
+
FROM information_schema.table_constraints tc
|
|
181
|
+
JOIN information_schema.constraint_column_usage AS ccu USING (constraint_schema, constraint_name)
|
|
182
|
+
JOIN information_schema.columns AS c ON c.table_schema = tc.constraint_schema
|
|
183
|
+
AND tc.table_name = c.table_name AND ccu.column_name = c.column_name
|
|
184
|
+
WHERE constraint_type = 'PRIMARY KEY' and tc.table_schema = ANY ($1::text[]);
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def load_foreign_keys(conn: asyncpg.Connection, schemas: list[str]):
|
|
189
|
+
data = await conn.fetch(SCHEMA_FK_QUERY, schemas)
|
|
190
|
+
return [FKConstraint(**x) for x in data]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def load_primary_keys(conn: asyncpg.Connection, schemas: list[str]):
|
|
194
|
+
data = await conn.fetch(SCHEMA_PK_QUERY, schemas)
|
|
195
|
+
return [PKConstraint(**x) for x in data]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
GET_COLUMNS_TYPE_QUERY = """
|
|
199
|
+
select
|
|
200
|
+
column_name, data_type
|
|
201
|
+
from information_schema.columns
|
|
202
|
+
where table_schema = $1 and
|
|
203
|
+
table_name = $2 and
|
|
204
|
+
column_name = ANY ($3::text[])
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def load_columns_type(conn: asyncpg.Connection, schema: str,
|
|
209
|
+
table: str,
|
|
210
|
+
columns: list[str]):
|
|
211
|
+
data = await conn.fetch(GET_COLUMNS_TYPE_QUERY, schema,
|
|
212
|
+
table, columns)
|
|
213
|
+
return functools.reduce(lambda p, n: {**p, **{n['column_name']: n['data_type']}},
|
|
214
|
+
data, {})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class Database:
|
|
219
|
+
name: str
|
|
220
|
+
tables: list[Table] = field(default_factory=list)
|
|
221
|
+
|
|
222
|
+
async def explore(self, pool: asyncpg.Pool, schemas: list[str], *, load_count: bool = True):
|
|
223
|
+
async with pool.acquire() as conn:
|
|
224
|
+
self.tables = await get_tables(conn, schemas)
|
|
225
|
+
fks = await load_foreign_keys(conn, schemas)
|
|
226
|
+
pks = await load_primary_keys(conn, schemas)
|
|
227
|
+
|
|
228
|
+
_tables: dict[str, Table] = {_table.full_name: _table for _table in self.tables}
|
|
229
|
+
|
|
230
|
+
for _pk in pks:
|
|
231
|
+
_tables[_pk.full_name].primary_keys.append(_pk)
|
|
232
|
+
|
|
233
|
+
for _fk in fks:
|
|
234
|
+
_tables[_fk.full_name].foreign_keys.append(_fk)
|
|
235
|
+
_tables[_fk.full_name].parent_tables.add(_tables[_fk.foreign_full_name])
|
|
236
|
+
_tables[_fk.foreign_full_name].child_tables.add(_tables[_fk.full_name])
|
|
237
|
+
|
|
238
|
+
if load_count:
|
|
239
|
+
await asyncio.gather(*[
|
|
240
|
+
get_conn(pool, table.load_count) for table in self.tables
|
|
241
|
+
])
|
|
242
|
+
|
|
243
|
+
def load_config(self, config: Config):
|
|
244
|
+
"""
|
|
245
|
+
Loads the sample sizes for each tables from the config file.
|
|
246
|
+
Tables need to have been loaded first
|
|
247
|
+
"""
|
|
248
|
+
_schemas: dict[str, SampleType | None] = {schema.schema: schema.sample for schema in config.schemas}
|
|
249
|
+
_tables: dict[str, SampleType | None] = {f'{_table.schema}.{_table.table}': _table.sample for _table in
|
|
250
|
+
config.tables}
|
|
251
|
+
for _table in self.tables:
|
|
252
|
+
_schema_sample = _schemas.get(_table.schema)
|
|
253
|
+
_table_sample = _tables.get(_table.full_name)
|
|
254
|
+
_sample = get_first(_table_sample,
|
|
255
|
+
_schema_sample,
|
|
256
|
+
config.sample,
|
|
257
|
+
fn=lambda x: x is not None)
|
|
258
|
+
if _sample is None:
|
|
259
|
+
raise ValueError('_sample must not be empty')
|
|
260
|
+
_table.sample_size = int(_sample)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def pretty_print_stats(database: Database):
|
|
264
|
+
table = RTable(title=f"Stats for {database.name}")
|
|
265
|
+
|
|
266
|
+
table.add_column("Table", justify="left", style="cyan")
|
|
267
|
+
table.add_column("Count", justify="right", style="green")
|
|
268
|
+
table.add_column("# FKs", justify="right", style="magenta")
|
|
269
|
+
table.add_column("# Parents", justify="right", style="magenta")
|
|
270
|
+
table.add_column("# Children", justify="right", style="magenta")
|
|
271
|
+
|
|
272
|
+
for _table in sorted(database.tables, key=lambda x: x.full_name):
|
|
273
|
+
table.add_row(_table.full_name, str(_table.count),
|
|
274
|
+
str(len(_table.foreign_keys)),
|
|
275
|
+
str(len(_table.parent_tables)),
|
|
276
|
+
str(len(_table.child_tables))
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
console = Console()
|
|
280
|
+
console.print(table)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def pprint_compared_dbs(db_1: Database, db_2: Database):
|
|
284
|
+
table = RTable(title=f"Comparing {db_1.name} and {db_2.name}")
|
|
285
|
+
|
|
286
|
+
table.add_column("Table", justify="left", style="blue")
|
|
287
|
+
table.add_column(f"Count {db_1.name!r}", justify="right", style="cyan")
|
|
288
|
+
table.add_column(f"Count {db_2.name!r}", justify="right", style="cyan")
|
|
289
|
+
table.add_column("Diff", justify="right", style="green")
|
|
290
|
+
|
|
291
|
+
tables_1, tables_2 = sorted(db_1.tables, key=lambda x: x.full_name), sorted(db_2.tables, key=lambda x: x.full_name)
|
|
292
|
+
|
|
293
|
+
for _table1, _table2 in zip(tables_1, tables_2):
|
|
294
|
+
perc_diff = 100 if _table2.count == 0 else int(_table2.count * 100 / _table1.count)
|
|
295
|
+
if perc_diff > 0:
|
|
296
|
+
pass
|
|
297
|
+
elif perc_diff < 0:
|
|
298
|
+
pass
|
|
299
|
+
else:
|
|
300
|
+
pass
|
|
301
|
+
table.add_row(_table1.full_name,
|
|
302
|
+
str(_table1.count),
|
|
303
|
+
str(_table2.count),
|
|
304
|
+
f'{perc_diff}%')
|
|
305
|
+
|
|
306
|
+
console = Console()
|
|
307
|
+
console.print(table)
|
padmy-0.4.0/padmy/env.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
PG_DATABASE = os.getenv('PG_DATABASE', 'postgres')
|
|
4
|
+
PG_HOST = os.getenv('PG_HOST', 'localhost')
|
|
5
|
+
PG_PORT = int(os.getenv('PG_PORT', '5432'))
|
|
6
|
+
PG_USER = os.getenv('PG_USER', 'postgres')
|
|
7
|
+
PG_PASSWORD = os.getenv('PG_PASSWORD', 'postgres')
|
|
8
|
+
|
|
9
|
+
# Migration
|
|
10
|
+
SQL_DIR = os.getenv('SQL_DIR')
|
|
11
|
+
MIGRATION_DIR = os.getenv('MIGRATION_DIR')
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from rich.logging import RichHandler
|
|
3
|
+
|
|
4
|
+
logs = logging.getLogger('padmy')
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def setup_logging(level: int):
|
|
8
|
+
logging.basicConfig(
|
|
9
|
+
datefmt="%H:%M:%S",
|
|
10
|
+
format="%(message)s",
|
|
11
|
+
handlers=[RichHandler(rich_tracebacks=False, show_path=False)]
|
|
12
|
+
)
|
|
13
|
+
logs.setLevel(level)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import textwrap
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from rich.prompt import Prompt
|
|
9
|
+
|
|
10
|
+
from padmy.logs import logs
|
|
11
|
+
from .utils import get_git_email, get_files, iter_migration_files
|
|
12
|
+
|
|
13
|
+
_CONSOLE = Console(markup=True, highlight=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_user_email() -> str | None:
|
|
17
|
+
default_author = get_git_email()
|
|
18
|
+
author = Prompt.ask("[blue]Author[/blue]", default=default_author,
|
|
19
|
+
console=_CONSOLE)
|
|
20
|
+
return author
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_last_migration_name(folder: Path) -> str | None:
|
|
24
|
+
""" Returns the most recent migration files"""
|
|
25
|
+
files = get_files(reverse=True, folder=folder)
|
|
26
|
+
if not files:
|
|
27
|
+
return None
|
|
28
|
+
up_file, down_file = next(iter_migration_files(files))
|
|
29
|
+
return up_file.path.name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_new_migration(folder: Path):
|
|
33
|
+
"""
|
|
34
|
+
Creates 2 new files, up and down
|
|
35
|
+
"""
|
|
36
|
+
folder.mkdir(exist_ok=True, parents=True)
|
|
37
|
+
|
|
38
|
+
_base_name = f'{int(time.time())}-{str(uuid.uuid4())[:8]}'
|
|
39
|
+
_CONSOLE.print(f'\nCreating new migration file ([green]{escape(_base_name)}[/green]):\n')
|
|
40
|
+
|
|
41
|
+
last_migration = _get_last_migration_name(folder)
|
|
42
|
+
logs.debug(f'Last migration files: {last_migration}')
|
|
43
|
+
author = _get_user_email()
|
|
44
|
+
logs.debug(f'User email: {author}')
|
|
45
|
+
|
|
46
|
+
up_file = folder / Path(f'{_base_name}-up.sql')
|
|
47
|
+
down_file = folder / Path(f'{_base_name}-down.sql')
|
|
48
|
+
|
|
49
|
+
file_header = textwrap.dedent(f"""
|
|
50
|
+
-- Prev-file: {last_migration or ''}
|
|
51
|
+
-- Author: {author or ''}
|
|
52
|
+
""").strip()
|
|
53
|
+
|
|
54
|
+
up_file.write_text(file_header)
|
|
55
|
+
down_file.write_text(file_header)
|
|
56
|
+
|
|
57
|
+
_CONSOLE.print('\nNew files created!\n')
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
SET SCHEMA 'public';
|
|
2
|
+
|
|
3
|
+
DO
|
|
4
|
+
$$
|
|
5
|
+
BEGIN
|
|
6
|
+
CREATE TYPE MIGRATION_TYPE AS ENUM (
|
|
7
|
+
'up',
|
|
8
|
+
'down'
|
|
9
|
+
);
|
|
10
|
+
EXCEPTION
|
|
11
|
+
WHEN duplicate_object THEN null;
|
|
12
|
+
END
|
|
13
|
+
$$;
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS public.migration
|
|
16
|
+
(
|
|
17
|
+
id serial PRIMARY KEY NOT NULL,
|
|
18
|
+
applied_at timestamp NOT NULL DEFAULT now(),
|
|
19
|
+
migration_type MIGRATION_TYPE NOT NULL,
|
|
20
|
+
file_name text NOT NULL,
|
|
21
|
+
file_ts TIMESTAMP NOT NULL,
|
|
22
|
+
file_id varchar(10) NOT NULL
|
|
23
|
+
);
|