pg-data-yaml 0.0.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.
- pg_data_yaml/__init__.py +1 -0
- pg_data_yaml/analyze_envs.py +75 -0
- pg_data_yaml/extractor.py +46 -0
- pg_data_yaml/formatter.py +49 -0
- pg_data_yaml/main.py +198 -0
- pg_data_yaml/merge_envs.py +128 -0
- pg_data_yaml/paths.py +13 -0
- pg_data_yaml/pg.py +75 -0
- pg_data_yaml/registry.py +158 -0
- pg_data_yaml/str_diff.py +22 -0
- pg_data_yaml/sync_comment.py +60 -0
- pg_data_yaml/synchronizer.py +230 -0
- pg_data_yaml/values.py +92 -0
- pg_data_yaml-0.0.0.dist-info/METADATA +265 -0
- pg_data_yaml-0.0.0.dist-info/RECORD +23 -0
- pg_data_yaml-0.0.0.dist-info/WHEEL +5 -0
- pg_data_yaml-0.0.0.dist-info/entry_points.txt +2 -0
- pg_data_yaml-0.0.0.dist-info/licenses/LICENSE +29 -0
- pg_data_yaml-0.0.0.dist-info/top_level.txt +2 -0
- tests/integration/__init__.py +0 -0
- tests/integration/conftest.py +30 -0
- tests/integration/helpers.py +231 -0
- tests/integration/test_export_diff_sync.py +274 -0
pg_data_yaml/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.0.0'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from .paths import list_table_yaml_files
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AnalyzeEnvs:
|
|
13
|
+
def __init__(self, args: argparse.Namespace):
|
|
14
|
+
self.args = args
|
|
15
|
+
self.sources = [os.path.abspath(path) for path in args.source]
|
|
16
|
+
|
|
17
|
+
def run(self) -> None:
|
|
18
|
+
self._validate()
|
|
19
|
+
synchronized_directories = open('/tmp/synchronized_directory.txt').read().split('\n')
|
|
20
|
+
env_files = [list_table_yaml_files(path) for path in self.sources]
|
|
21
|
+
all_paths = set.union(*[set(files) for files in env_files])
|
|
22
|
+
if not all_paths:
|
|
23
|
+
print('Nothing to analyze: no table files across environments')
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
for rel_path in sorted(all_paths):
|
|
27
|
+
lines = {}
|
|
28
|
+
postfix = ''
|
|
29
|
+
for env in self.sources:
|
|
30
|
+
file_path = os.path.join(env, rel_path)
|
|
31
|
+
if os.path.isfile(file_path):
|
|
32
|
+
for line in yaml.safe_load(open(file_path)):
|
|
33
|
+
line = str(line)
|
|
34
|
+
lines[line] = lines.get(line, 0) + 1
|
|
35
|
+
else:
|
|
36
|
+
postfix = '*'
|
|
37
|
+
identical_lines = sum(1 for count in lines.values() if count == len(self.sources))
|
|
38
|
+
table = self.path_to_table_name(rel_path)
|
|
39
|
+
sync_dir = "да" if table in synchronized_directories else ''
|
|
40
|
+
print(f'{table}\t{identical_lines}/{len(lines) - identical_lines}{postfix}\t{sync_dir}')
|
|
41
|
+
|
|
42
|
+
# def run_base(self) -> None:
|
|
43
|
+
# synchronized_directories=open('/tmp/synchronized_directory.txt').read().split('\n')
|
|
44
|
+
# env_files = [list_table_yaml_files(path) for path in self.sources]
|
|
45
|
+
# all_paths = set.union(*[set(files) for files in env_files])
|
|
46
|
+
# if not all_paths:
|
|
47
|
+
# print('Nothing to analyze: no table files across environments')
|
|
48
|
+
# return
|
|
49
|
+
|
|
50
|
+
# for rel_path in sorted(all_paths):
|
|
51
|
+
# env = self.sources[0]
|
|
52
|
+
# file_path = os.path.join(env, rel_path)
|
|
53
|
+
# lines_count = len(yaml.safe_load(open(file_path)))
|
|
54
|
+
# table = self.path_to_table_name(rel_path)
|
|
55
|
+
# sync_dir="да" if table in synchronized_directories else ''
|
|
56
|
+
# print(f'{table}\t{lines_count}/0\t{sync_dir}')
|
|
57
|
+
|
|
58
|
+
def _validate(self) -> None:
|
|
59
|
+
if len(self.sources) < 2:
|
|
60
|
+
print('ERROR: specify at least two --source directories', file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
seen = set()
|
|
64
|
+
for path in self.sources:
|
|
65
|
+
if path in seen:
|
|
66
|
+
print(f'ERROR: duplicate source directory: {path}', file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
seen.add(path)
|
|
69
|
+
if not os.path.isdir(path):
|
|
70
|
+
print(f'ERROR: source directory not found: {path}', file=sys.stderr)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def path_to_table_name(path: str) -> str:
|
|
75
|
+
return '{}.{}'.format(*path[:-5].split('/'))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from .formatter import Formatter
|
|
5
|
+
from .pg import Pg
|
|
6
|
+
from .registry import SyncTable, TableRegistry
|
|
7
|
+
from .values import ordered_row
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Extractor:
|
|
11
|
+
ROWS_LIMIT = 50000
|
|
12
|
+
|
|
13
|
+
def __init__(self, args: argparse.Namespace, pg: Pg):
|
|
14
|
+
self.args = args
|
|
15
|
+
self.pg = pg
|
|
16
|
+
self.registry = TableRegistry.from_args(pg, args)
|
|
17
|
+
self.formatter = Formatter()
|
|
18
|
+
|
|
19
|
+
async def export(self) -> None:
|
|
20
|
+
tables = await self.registry.load()
|
|
21
|
+
for sync_table in tables.values():
|
|
22
|
+
rows_count = await self.get_rows_count(sync_table)
|
|
23
|
+
if rows_count == self.ROWS_LIMIT:
|
|
24
|
+
print(f'Table {sync_table.table} has {rows_count} rows, skipping')
|
|
25
|
+
continue
|
|
26
|
+
rows = await self.fetch_rows(sync_table)
|
|
27
|
+
file_name = sync_table.file_path(self.args.out_dir)
|
|
28
|
+
os.makedirs(os.path.dirname(file_name), exist_ok=True)
|
|
29
|
+
self.formatter.dump(rows, file_name)
|
|
30
|
+
|
|
31
|
+
async def fetch_rows(self, sync_table: SyncTable) -> list[dict]:
|
|
32
|
+
rows = await self.pg.fetch(sync_table.select_query())
|
|
33
|
+
return [ordered_row(row) for row in rows]
|
|
34
|
+
|
|
35
|
+
async def get_rows_count(self, sync_table: SyncTable) -> list[dict]:
|
|
36
|
+
query = sync_table.select_query()
|
|
37
|
+
query = f'select count(*) from ({query} limit {self.ROWS_LIMIT}) as t'
|
|
38
|
+
rows = await self.pg.fetch(query)
|
|
39
|
+
return rows[0]['count']
|
|
40
|
+
|
|
41
|
+
async def get_tables_data(self) -> dict[tuple[str, str], list[dict]]:
|
|
42
|
+
tables = await self.registry.load()
|
|
43
|
+
return {
|
|
44
|
+
key: await self.fetch_rows(sync_table)
|
|
45
|
+
for key, sync_table in tables.items()
|
|
46
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
_INTERVAL_STR_RE = re.compile(r'^-?(\d+ days? )?\d+:\d{2}:\d{2}$')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Formatter:
|
|
10
|
+
_representers_registered = False
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self._register_representers()
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def _register_representers(cls):
|
|
17
|
+
if cls._representers_registered:
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
def str_presenter(dumper, data):
|
|
21
|
+
if '\n' in data:
|
|
22
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
|
|
23
|
+
if _INTERVAL_STR_RE.fullmatch(data):
|
|
24
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='')
|
|
25
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', data)
|
|
26
|
+
|
|
27
|
+
yaml.add_representer(str, str_presenter)
|
|
28
|
+
cls._representers_registered = True
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def dump(data: Any, file_name: str = None):
|
|
32
|
+
Formatter._register_representers()
|
|
33
|
+
if data is None:
|
|
34
|
+
return ''
|
|
35
|
+
file = None
|
|
36
|
+
if file_name:
|
|
37
|
+
file = open(file_name, 'w')
|
|
38
|
+
return yaml.dump(
|
|
39
|
+
data,
|
|
40
|
+
file,
|
|
41
|
+
allow_unicode=True,
|
|
42
|
+
sort_keys=False,
|
|
43
|
+
width=float('inf'),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def load(file_name: str) -> Any:
|
|
48
|
+
with open(file_name) as file:
|
|
49
|
+
return yaml.safe_load(file)
|
pg_data_yaml/main.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .analyze_envs import AnalyzeEnvs
|
|
8
|
+
from .extractor import Extractor
|
|
9
|
+
from .merge_envs import MergeEnvs
|
|
10
|
+
from .pg import Pg
|
|
11
|
+
from .synchronizer import Synchronizer
|
|
12
|
+
from . import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def run(args):
|
|
16
|
+
pg = Pg(args)
|
|
17
|
+
await pg.init()
|
|
18
|
+
|
|
19
|
+
if args.command == 'export':
|
|
20
|
+
await Extractor(args, pg).export()
|
|
21
|
+
|
|
22
|
+
elif args.command in ('diff', 'sync'):
|
|
23
|
+
await Synchronizer(args, pg).sync(show_diff_only=args.command == 'diff')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
def add_connection_args(parser):
|
|
28
|
+
parser.add_argument('-d', '--dbname',
|
|
29
|
+
type=str, help='database name to connect to')
|
|
30
|
+
parser.add_argument('-h', '--host',
|
|
31
|
+
type=str, help='database server host or socket directory')
|
|
32
|
+
parser.add_argument('-p', '--port',
|
|
33
|
+
type=str, help='database server port')
|
|
34
|
+
parser.add_argument('-U', '--user',
|
|
35
|
+
type=str, help='database user name')
|
|
36
|
+
parser.add_argument('-W', '--password',
|
|
37
|
+
type=str, help='database user password')
|
|
38
|
+
|
|
39
|
+
def add_registry_args(parser):
|
|
40
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
41
|
+
group.add_argument(
|
|
42
|
+
'--comment-label',
|
|
43
|
+
metavar='LABEL',
|
|
44
|
+
help='include tables whose comment contains LABEL; '
|
|
45
|
+
'optional export query or WHERE filter in parentheses after the label',
|
|
46
|
+
)
|
|
47
|
+
group.add_argument(
|
|
48
|
+
'--table-list-predicate',
|
|
49
|
+
metavar='PREDICATE',
|
|
50
|
+
help='include tables matching SQL PREDICATE (table comments are ignored); '
|
|
51
|
+
'references n.nspname and c.relname from pg_catalog',
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
arg_parser = argparse.ArgumentParser(
|
|
55
|
+
epilog='Report bugs: https://github.com/andruche/pg-data-yaml/issues',
|
|
56
|
+
conflict_handler='resolve',
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
arg_parser.add_argument(
|
|
60
|
+
'--version',
|
|
61
|
+
action='version',
|
|
62
|
+
version=__version__,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
subparsers = arg_parser.add_subparsers(
|
|
66
|
+
dest='command',
|
|
67
|
+
title='commands',
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
parser_export = subparsers.add_parser(
|
|
71
|
+
'export',
|
|
72
|
+
help='export synchronized reference tables to yaml files',
|
|
73
|
+
conflict_handler='resolve',
|
|
74
|
+
)
|
|
75
|
+
add_connection_args(parser_export)
|
|
76
|
+
add_registry_args(parser_export)
|
|
77
|
+
parser_export.add_argument(
|
|
78
|
+
'--out-dir',
|
|
79
|
+
required=True,
|
|
80
|
+
help='directory for exporting files',
|
|
81
|
+
)
|
|
82
|
+
parser_export.add_argument(
|
|
83
|
+
'--clean',
|
|
84
|
+
action='store_true',
|
|
85
|
+
help='clean out_dir if not empty '
|
|
86
|
+
'(env variable DATA_DIRECTORY_AUTOCLEAN=true)',
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
parser_diff = subparsers.add_parser(
|
|
90
|
+
'diff',
|
|
91
|
+
help='show diff between database and yaml files',
|
|
92
|
+
conflict_handler='resolve',
|
|
93
|
+
)
|
|
94
|
+
add_connection_args(parser_diff)
|
|
95
|
+
add_registry_args(parser_diff)
|
|
96
|
+
parser_diff.add_argument(
|
|
97
|
+
'--source',
|
|
98
|
+
required=True,
|
|
99
|
+
help='directory or file with table data to compare with database',
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
parser_sync = subparsers.add_parser(
|
|
103
|
+
'sync',
|
|
104
|
+
help='sync yaml files to database tables',
|
|
105
|
+
conflict_handler='resolve',
|
|
106
|
+
)
|
|
107
|
+
add_connection_args(parser_sync)
|
|
108
|
+
add_registry_args(parser_sync)
|
|
109
|
+
parser_sync.add_argument(
|
|
110
|
+
'--source',
|
|
111
|
+
required=True,
|
|
112
|
+
help='directory or file with table data to sync to database',
|
|
113
|
+
)
|
|
114
|
+
parser_sync.add_argument(
|
|
115
|
+
'--dry-run',
|
|
116
|
+
action='store_true',
|
|
117
|
+
help='test run without real changes',
|
|
118
|
+
)
|
|
119
|
+
parser_sync.add_argument(
|
|
120
|
+
'--echo-queries',
|
|
121
|
+
action='store_true',
|
|
122
|
+
help='echo commands sent to server',
|
|
123
|
+
)
|
|
124
|
+
parser_sync.add_argument(
|
|
125
|
+
'-y', '--yes',
|
|
126
|
+
action='store_true',
|
|
127
|
+
help='do not ask confirm',
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
parser_merge_envs = subparsers.add_parser(
|
|
131
|
+
'merge-envs',
|
|
132
|
+
help='move identical table files from env dirs to base directory',
|
|
133
|
+
conflict_handler='resolve',
|
|
134
|
+
)
|
|
135
|
+
parser_merge_envs.add_argument(
|
|
136
|
+
'--source',
|
|
137
|
+
action='append',
|
|
138
|
+
required=True,
|
|
139
|
+
metavar='ENV_DIR',
|
|
140
|
+
help='environment directory (can be repeated)',
|
|
141
|
+
)
|
|
142
|
+
parser_merge_envs.add_argument(
|
|
143
|
+
'--out-dir',
|
|
144
|
+
required=True,
|
|
145
|
+
help='base directory for common table files',
|
|
146
|
+
)
|
|
147
|
+
parser_merge_envs.add_argument(
|
|
148
|
+
'--dry-run',
|
|
149
|
+
action='store_true',
|
|
150
|
+
help='show actions without changing files',
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
parser_merge_envs = subparsers.add_parser(
|
|
154
|
+
'analyze-envs',
|
|
155
|
+
help='compare data between envs',
|
|
156
|
+
conflict_handler='resolve',
|
|
157
|
+
)
|
|
158
|
+
parser_merge_envs.add_argument(
|
|
159
|
+
'--source',
|
|
160
|
+
action='append',
|
|
161
|
+
required=True,
|
|
162
|
+
metavar='ENV_DIR',
|
|
163
|
+
help='environment directory (can be repeated)',
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
args = arg_parser.parse_args()
|
|
167
|
+
if not args.command:
|
|
168
|
+
arg_parser.print_help()
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
|
|
171
|
+
if args.command == 'export':
|
|
172
|
+
if os.path.exists(args.out_dir) and os.listdir(args.out_dir):
|
|
173
|
+
if args.clean or os.environ.get('DATA_DIRECTORY_AUTOCLEAN') == 'true':
|
|
174
|
+
shutil.rmtree(args.out_dir)
|
|
175
|
+
else:
|
|
176
|
+
parser_export.error(
|
|
177
|
+
'out_dir directory not empty (you can use option --clean)'
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
os.makedirs(args.out_dir, exist_ok=True)
|
|
181
|
+
except OSError:
|
|
182
|
+
arg_parser.error("can not access to directory '%s'" % args.out_dir)
|
|
183
|
+
|
|
184
|
+
if args.command in ('diff', 'sync'):
|
|
185
|
+
if not os.path.exists(args.source):
|
|
186
|
+
arg_parser.error(f'file or directory not found: {args.source}')
|
|
187
|
+
|
|
188
|
+
if args.command == 'merge-envs':
|
|
189
|
+
MergeEnvs(args).run()
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
if args.command == 'analyze-envs':
|
|
193
|
+
AnalyzeEnvs(args).run_base()
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if os.name == 'nt':
|
|
197
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
198
|
+
asyncio.run(run(args))
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .paths import list_table_yaml_files
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MergeEnvs:
|
|
12
|
+
def __init__(self, args: argparse.Namespace):
|
|
13
|
+
self.args = args
|
|
14
|
+
self.sources = [os.path.abspath(path) for path in args.source]
|
|
15
|
+
self.out_dir = os.path.abspath(args.out_dir)
|
|
16
|
+
|
|
17
|
+
def run(self) -> None:
|
|
18
|
+
self._validate()
|
|
19
|
+
env_files = [list_table_yaml_files(path) for path in self.sources]
|
|
20
|
+
common_paths = set.intersection(*[set(files) for files in env_files])
|
|
21
|
+
if not common_paths:
|
|
22
|
+
print('Nothing to merge: no common table files across environments')
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
merged = 0
|
|
26
|
+
skipped_diff = 0
|
|
27
|
+
skipped_base_conflict = 0
|
|
28
|
+
|
|
29
|
+
for rel_path in sorted(common_paths):
|
|
30
|
+
source_paths = [files[rel_path] for files in env_files]
|
|
31
|
+
if not self._files_identical(source_paths):
|
|
32
|
+
print(
|
|
33
|
+
f'SKIP: {rel_path} differs between environments',
|
|
34
|
+
file=sys.stderr,
|
|
35
|
+
)
|
|
36
|
+
skipped_diff += 1
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
base_path = os.path.join(self.out_dir, rel_path)
|
|
40
|
+
if os.path.exists(base_path):
|
|
41
|
+
if not self._files_identical([source_paths[0], base_path]):
|
|
42
|
+
print(
|
|
43
|
+
f'SKIP: {rel_path} in base dir differs from environments',
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
skipped_base_conflict += 1
|
|
47
|
+
continue
|
|
48
|
+
self._remove_from_sources(rel_path, source_paths)
|
|
49
|
+
print(f'merged (already in base): {rel_path}')
|
|
50
|
+
else:
|
|
51
|
+
self._move_to_base(rel_path, source_paths)
|
|
52
|
+
print(f'merged: {rel_path}')
|
|
53
|
+
merged += 1
|
|
54
|
+
|
|
55
|
+
print(
|
|
56
|
+
f'Done: merged {merged}, '
|
|
57
|
+
f'skipped {skipped_diff} (differs between envs), '
|
|
58
|
+
f'skipped {skipped_base_conflict} (conflicts with base)'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _validate(self) -> None:
|
|
62
|
+
if len(self.sources) < 2:
|
|
63
|
+
print('ERROR: specify at least two --source directories', file=sys.stderr)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
seen = set()
|
|
67
|
+
for path in self.sources:
|
|
68
|
+
if path in seen:
|
|
69
|
+
print(f'ERROR: duplicate source directory: {path}', file=sys.stderr)
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
seen.add(path)
|
|
72
|
+
if not os.path.isdir(path):
|
|
73
|
+
print(f'ERROR: source directory not found: {path}', file=sys.stderr)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
if self.out_dir in self.sources:
|
|
77
|
+
print('ERROR: --out-dir must not be one of --source directories', file=sys.stderr)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
for path in self.sources:
|
|
81
|
+
if os.path.commonpath([path, self.out_dir]) == self.out_dir:
|
|
82
|
+
print(
|
|
83
|
+
f'ERROR: source directory inside --out-dir is not supported: {path}',
|
|
84
|
+
file=sys.stderr,
|
|
85
|
+
)
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
if os.path.commonpath([path, self.out_dir]) == path:
|
|
88
|
+
print(
|
|
89
|
+
f'ERROR: --out-dir inside source directory is not supported: {path}',
|
|
90
|
+
file=sys.stderr,
|
|
91
|
+
)
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
if not self.args.dry_run:
|
|
95
|
+
os.makedirs(self.out_dir, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _files_identical(paths: list[str]) -> bool:
|
|
99
|
+
with open(paths[0], 'rb') as first:
|
|
100
|
+
content = first.read()
|
|
101
|
+
for path in paths[1:]:
|
|
102
|
+
with open(path, 'rb') as other:
|
|
103
|
+
if other.read() != content:
|
|
104
|
+
return False
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def _move_to_base(self, rel_path: str, source_paths: list[str]) -> None:
|
|
108
|
+
base_path = os.path.join(self.out_dir, rel_path)
|
|
109
|
+
if self.args.dry_run:
|
|
110
|
+
return
|
|
111
|
+
os.makedirs(os.path.dirname(base_path), exist_ok=True)
|
|
112
|
+
shutil.move(source_paths[0], base_path)
|
|
113
|
+
for path in source_paths[1:]:
|
|
114
|
+
os.remove(path)
|
|
115
|
+
|
|
116
|
+
def _remove_from_sources(self, rel_path: str, source_paths: list[str]) -> None:
|
|
117
|
+
if self.args.dry_run:
|
|
118
|
+
return
|
|
119
|
+
for path in source_paths:
|
|
120
|
+
os.remove(path)
|
|
121
|
+
self._remove_empty_dirs(source_paths)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _remove_empty_dirs(file_paths: list[str]) -> None:
|
|
125
|
+
dirs = {os.path.dirname(path) for path in file_paths}
|
|
126
|
+
for directory in sorted(dirs, key=len, reverse=True):
|
|
127
|
+
if os.path.isdir(directory) and not os.listdir(directory):
|
|
128
|
+
os.rmdir(directory)
|
pg_data_yaml/paths.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def list_table_yaml_files(root_dir: str) -> dict[str, str]:
|
|
8
|
+
"""Return mapping rel_path (schema/table.yaml) -> absolute path."""
|
|
9
|
+
files = {}
|
|
10
|
+
for path in glob.glob(os.path.join(root_dir, '*', '*.yaml')):
|
|
11
|
+
rel_path = os.path.relpath(path, root_dir)
|
|
12
|
+
files[rel_path] = path
|
|
13
|
+
return files
|
pg_data_yaml/pg.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import datetime
|
|
3
|
+
import decimal
|
|
4
|
+
import json
|
|
5
|
+
import signal
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
import asyncpg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def quote_ident(name: str) -> str:
|
|
12
|
+
return '"' + name.replace('"', '""') + '"'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def quote_literal(value):
|
|
16
|
+
if value is None:
|
|
17
|
+
return 'null'
|
|
18
|
+
if isinstance(value, bool):
|
|
19
|
+
return 'true' if value else 'false'
|
|
20
|
+
if isinstance(value, (int, float)):
|
|
21
|
+
return str(value)
|
|
22
|
+
if isinstance(value, decimal.Decimal):
|
|
23
|
+
return str(value)
|
|
24
|
+
if isinstance(value, uuid.UUID):
|
|
25
|
+
return quote_literal(str(value))
|
|
26
|
+
if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
|
|
27
|
+
return quote_literal(value.isoformat())
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
return "'" + value.replace("'", "''") + "'"
|
|
30
|
+
if isinstance(value, (dict, list)):
|
|
31
|
+
return quote_literal(json.dumps(value))
|
|
32
|
+
if isinstance(value, (bytes, memoryview)):
|
|
33
|
+
return quote_literal(bytes(value).hex())
|
|
34
|
+
raise TypeError(f'Unknown type for quote value: {value!r}')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Pg:
|
|
38
|
+
con: asyncpg.Connection
|
|
39
|
+
|
|
40
|
+
def __init__(self, args):
|
|
41
|
+
self.args = args
|
|
42
|
+
|
|
43
|
+
async def init(self):
|
|
44
|
+
self.con = await asyncpg.connect(
|
|
45
|
+
database=self.args.dbname,
|
|
46
|
+
user=self.args.user,
|
|
47
|
+
password=self.args.password,
|
|
48
|
+
host=self.args.host,
|
|
49
|
+
port=self.args.port,
|
|
50
|
+
statement_cache_size=0,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def fetch(self, query: str, *params) -> list[dict]:
|
|
54
|
+
query_task = asyncio.create_task(self.con.fetch(query, *params))
|
|
55
|
+
loop = asyncio.get_running_loop()
|
|
56
|
+
loop.add_signal_handler(signal.SIGINT, query_task.cancel)
|
|
57
|
+
try:
|
|
58
|
+
rows = await query_task
|
|
59
|
+
return [{key: row[key] for key in row.keys()} for row in rows]
|
|
60
|
+
except asyncio.CancelledError:
|
|
61
|
+
await asyncio.sleep(0.5)
|
|
62
|
+
return []
|
|
63
|
+
finally:
|
|
64
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
65
|
+
|
|
66
|
+
async def execute(self, query: str, *params) -> None:
|
|
67
|
+
query_task = asyncio.create_task(self.con.execute(query, *params))
|
|
68
|
+
loop = asyncio.get_running_loop()
|
|
69
|
+
loop.add_signal_handler(signal.SIGINT, query_task.cancel)
|
|
70
|
+
try:
|
|
71
|
+
await query_task
|
|
72
|
+
except asyncio.CancelledError:
|
|
73
|
+
await asyncio.sleep(0.5)
|
|
74
|
+
finally:
|
|
75
|
+
loop.remove_signal_handler(signal.SIGINT)
|