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.
@@ -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)