al-data-utils 0.3.1__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.
- al_data_utils/__init__.py +10 -0
- al_data_utils/airflow_utils/__init__.py +13 -0
- al_data_utils/airflow_utils/airflow_utils.py +83 -0
- al_data_utils/azure_as_utils/__init__.py +31 -0
- al_data_utils/azure_as_utils/auth.py +82 -0
- al_data_utils/azure_as_utils/azure_as_utils.py +223 -0
- al_data_utils/azure_as_utils/client.py +158 -0
- al_data_utils/azure_as_utils/errors.py +78 -0
- al_data_utils/azure_as_utils/models.py +265 -0
- al_data_utils/azure_as_utils/poller.py +121 -0
- al_data_utils/backup_utils/__init__.py +9 -0
- al_data_utils/backup_utils/mssql/dacpac/__init__.py +1 -0
- al_data_utils/backup_utils/mssql/dacpac/sqlpackage.py +207 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/__init__.py +8 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/config.py +150 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/__init__.py +11 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/foreign_keys.py +38 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/indexes.py +37 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/sql_objects.py +17 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/tables.py +139 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/__init__.py +19 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/foreign_keys.py +173 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/functions.py +133 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/indexes.py +190 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/sequences.py +180 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/stored_procedures.py +313 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/synonyms.py +135 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/tables.py +251 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/views.py +244 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/io/__init__.py +10 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/io/layout.py +28 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/io/writer.py +40 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/__init__.py +14 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/foreign_key_def.py +19 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/index_def.py +19 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/sql_object_def.py +11 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/table_column_def.py +27 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/table_def.py +22 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/snapshot_processor.py +754 -0
- al_data_utils/backup_utils/mssql/scripter/__init__.py +1 -0
- al_data_utils/config_utils/__init__.py +6 -0
- al_data_utils/config_utils/config_utils.py +31 -0
- al_data_utils/files_sync_utils/__init__.py +15 -0
- al_data_utils/files_sync_utils/files_sync_utils.py +567 -0
- al_data_utils/geo_utils/__init__.py +0 -0
- al_data_utils/git_utils/__init__.py +27 -0
- al_data_utils/git_utils/git_utils.py +142 -0
- al_data_utils/logging_utils/__init__.py +3 -0
- al_data_utils/logging_utils/logging_utils.py +39 -0
- al_data_utils/ones_api_utils/__init__.py +3 -0
- al_data_utils/ones_api_utils/ones_api_utils.py +61 -0
- al_data_utils/sql_utils/__init__.py +3 -0
- al_data_utils/sql_utils/sql_utils.py +158 -0
- al_data_utils/tg_utils/__init__.py +3 -0
- al_data_utils/tg_utils/tg_utils.py +45 -0
- al_data_utils-0.3.1.dist-info/METADATA +157 -0
- al_data_utils-0.3.1.dist-info/RECORD +60 -0
- al_data_utils-0.3.1.dist-info/WHEEL +5 -0
- al_data_utils-0.3.1.dist-info/licenses/LICENSE +21 -0
- al_data_utils-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# placeholder for future MSSQL scripting utilities
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from al_data_utils import logging_utils
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load_config(config_file: str) -> dict:
|
|
7
|
+
"""Loads a YAML configuration file safely.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
config_file (str): Path to the YAML file.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
dict: Parsed YAML content.
|
|
14
|
+
"""
|
|
15
|
+
with open(config_file, 'r', encoding='utf-8') as file:
|
|
16
|
+
config = yaml.safe_load(file)
|
|
17
|
+
|
|
18
|
+
return config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def save_config(config_json: dict, destination_config_file: str) -> None:
|
|
22
|
+
"""Stores a dictionary as a YAML configuration file.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
config_json (dict): Configuration data to save.
|
|
26
|
+
destination_config_file (str): Path to the YAML file.
|
|
27
|
+
"""
|
|
28
|
+
os.makedirs(os.path.dirname(destination_config_file), exist_ok=True)
|
|
29
|
+
with open(destination_config_file, 'w', encoding='utf-8') as file:
|
|
30
|
+
yaml.safe_dump(config_json, file, default_flow_style=False, allow_unicode=True)
|
|
31
|
+
logging_utils.info(f'successfully stored config in: {destination_config_file}')
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from al_data_utils import logging_utils
|
|
12
|
+
|
|
13
|
+
# =========================
|
|
14
|
+
# Config
|
|
15
|
+
# =========================
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SyncConfig:
|
|
20
|
+
source_paths: list[str] | dict[str, str]
|
|
21
|
+
target_path: str
|
|
22
|
+
log_folder: str
|
|
23
|
+
|
|
24
|
+
# COPY: if path matches these => DO NOT COPY. If exists in destination => DELETE it there.
|
|
25
|
+
copy_folders_names_to_skip: list[str]
|
|
26
|
+
copy_files_names_to_skip: list[str]
|
|
27
|
+
|
|
28
|
+
# DELETE: if extra path exists in destination (missing in source) and matches these => DO NOT DELETE
|
|
29
|
+
delete_folders_names_to_skip: list[str]
|
|
30
|
+
delete_files_names_to_skip: list[str]
|
|
31
|
+
|
|
32
|
+
# DELETE TRY: if deletion fails => mark as try_delete_failed (not fatal)
|
|
33
|
+
delete_folders_names_to_try: list[str]
|
|
34
|
+
delete_files_names_to_try: list[str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# =========================
|
|
38
|
+
# Helpers
|
|
39
|
+
# =========================
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _lower_set(items: list[str]) -> set[str]:
|
|
43
|
+
return {x.lower() for x in (items or [])}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_under_folder_name(rel_path: Path, names_lower: set[str]) -> bool:
|
|
47
|
+
return any(part.lower() in names_lower for part in rel_path.parts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _files_equal_by_stat(src: Path, dst: Path) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
s1 = src.stat()
|
|
53
|
+
s2 = dst.stat()
|
|
54
|
+
return s1.st_size == s2.st_size and int(s1.st_mtime) == int(s2.st_mtime)
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _safe_name_lower(p: object) -> str:
|
|
60
|
+
try:
|
|
61
|
+
if p is None:
|
|
62
|
+
return ''
|
|
63
|
+
if cast(bool, pd.isna(cast(Any, p))):
|
|
64
|
+
return ''
|
|
65
|
+
return Path(str(p)).name.lower()
|
|
66
|
+
except Exception:
|
|
67
|
+
return ''
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# =========================
|
|
71
|
+
# Inventory: "full list" but doesn't explode
|
|
72
|
+
# - we LIST excluded dirs, but DON'T descend into them
|
|
73
|
+
# =========================
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_inventory(
|
|
77
|
+
root: Path,
|
|
78
|
+
folders_to_not_descend_lower: set[str] | None = None,
|
|
79
|
+
files_to_mark_excluded_lower: set[str] | None = None,
|
|
80
|
+
) -> pd.DataFrame:
|
|
81
|
+
cols = ['kind', 'rel_path', 'abs_path', 'excluded', 'excluded_reason']
|
|
82
|
+
|
|
83
|
+
folders_to_not_descend_lower = folders_to_not_descend_lower or set()
|
|
84
|
+
files_to_mark_excluded_lower = files_to_mark_excluded_lower or set()
|
|
85
|
+
|
|
86
|
+
rows: list[dict] = []
|
|
87
|
+
if not root.exists():
|
|
88
|
+
return pd.DataFrame(columns=cols)
|
|
89
|
+
|
|
90
|
+
for cur_root, dirnames, filenames in os.walk(root, topdown=True):
|
|
91
|
+
cur = Path(cur_root)
|
|
92
|
+
rel_dir = cur.relative_to(root)
|
|
93
|
+
|
|
94
|
+
# record current directory (except ".")
|
|
95
|
+
if rel_dir != Path('.'):
|
|
96
|
+
rows.append(
|
|
97
|
+
dict(
|
|
98
|
+
kind='dir',
|
|
99
|
+
rel_path=str(rel_dir),
|
|
100
|
+
abs_path=str(cur),
|
|
101
|
+
excluded=False,
|
|
102
|
+
excluded_reason='',
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# record excluded dirs but don't descend
|
|
107
|
+
kept_dirs: list[str] = []
|
|
108
|
+
for d in dirnames:
|
|
109
|
+
if d.lower() in folders_to_not_descend_lower:
|
|
110
|
+
rel_ex_dir = (cur / d).relative_to(root)
|
|
111
|
+
rows.append(
|
|
112
|
+
dict(
|
|
113
|
+
kind='dir',
|
|
114
|
+
rel_path=str(rel_ex_dir),
|
|
115
|
+
abs_path=str(cur / d),
|
|
116
|
+
excluded=True,
|
|
117
|
+
excluded_reason='excluded_dir',
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
kept_dirs.append(d)
|
|
122
|
+
dirnames[:] = kept_dirs
|
|
123
|
+
|
|
124
|
+
# record files
|
|
125
|
+
for fn in filenames:
|
|
126
|
+
rel_file = (cur / fn).relative_to(root)
|
|
127
|
+
excluded = fn.lower() in files_to_mark_excluded_lower
|
|
128
|
+
rows.append(
|
|
129
|
+
dict(
|
|
130
|
+
kind='file',
|
|
131
|
+
rel_path=str(rel_file),
|
|
132
|
+
abs_path=str(cur / fn),
|
|
133
|
+
excluded=excluded,
|
|
134
|
+
excluded_reason='excluded_file' if excluded else '',
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Keep a stable schema even when the root exists but contains no files/dirs.
|
|
139
|
+
return pd.DataFrame(rows, columns=cols)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# =========================
|
|
143
|
+
# Plan
|
|
144
|
+
# =========================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_plan_df(cfg: SyncConfig) -> pd.DataFrame:
|
|
148
|
+
sources = cfg.source_paths
|
|
149
|
+
target_root = Path(cfg.target_path)
|
|
150
|
+
|
|
151
|
+
copy_skip_dirs = _lower_set(cfg.copy_folders_names_to_skip)
|
|
152
|
+
copy_skip_files = _lower_set(cfg.copy_files_names_to_skip)
|
|
153
|
+
|
|
154
|
+
del_skip_dirs = _lower_set(cfg.delete_folders_names_to_skip)
|
|
155
|
+
del_skip_files = _lower_set(cfg.delete_files_names_to_skip)
|
|
156
|
+
|
|
157
|
+
del_try_dirs = _lower_set(cfg.delete_folders_names_to_try)
|
|
158
|
+
del_try_files = _lower_set(cfg.delete_files_names_to_try)
|
|
159
|
+
|
|
160
|
+
src_inventories = []
|
|
161
|
+
dest_root = target_root
|
|
162
|
+
|
|
163
|
+
for src_root in sources:
|
|
164
|
+
src_inv = build_inventory(
|
|
165
|
+
Path(src_root),
|
|
166
|
+
folders_to_not_descend_lower=copy_skip_dirs, # don't enumerate inside copy-skip dirs
|
|
167
|
+
files_to_mark_excluded_lower=copy_skip_files, # list but mark excluded (no copy)
|
|
168
|
+
).rename(
|
|
169
|
+
columns={
|
|
170
|
+
'abs_path': 'src_path',
|
|
171
|
+
'excluded': 'src_excluded',
|
|
172
|
+
'excluded_reason': 'src_excluded_reason',
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
src_inv['source_root'] = str(src_root)
|
|
176
|
+
src_inventories.append(src_inv)
|
|
177
|
+
|
|
178
|
+
# concat into one DF
|
|
179
|
+
src_inv = pd.concat(src_inventories, ignore_index=True)
|
|
180
|
+
|
|
181
|
+
dst_inv = build_inventory(
|
|
182
|
+
dest_root,
|
|
183
|
+
folders_to_not_descend_lower=None, # don't enumerate inside copy-skip dirs
|
|
184
|
+
files_to_mark_excluded_lower=None,
|
|
185
|
+
).rename(
|
|
186
|
+
columns={
|
|
187
|
+
'abs_path': 'dst_path',
|
|
188
|
+
'excluded': 'dst_excluded',
|
|
189
|
+
'excluded_reason': 'dst_excluded_reason',
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
plan = src_inv.merge(dst_inv, on=['kind', 'rel_path'], how='outer')
|
|
194
|
+
plan['source_root'] = str(src_root)
|
|
195
|
+
plan['dest_root'] = str(dest_root)
|
|
196
|
+
|
|
197
|
+
plan['src_exists'] = plan['src_path'].notna()
|
|
198
|
+
plan['dst_exists'] = plan['dst_path'].notna()
|
|
199
|
+
|
|
200
|
+
rel_p = plan['rel_path'].fillna('').map(Path)
|
|
201
|
+
|
|
202
|
+
# copy-skip by folder subtree
|
|
203
|
+
plan['_under_copy_skip_dir'] = rel_p.map(
|
|
204
|
+
lambda p: _is_under_folder_name(p, copy_skip_dirs)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# copy-skip by file name (for files only)
|
|
208
|
+
src_name_lower = plan['src_path'].map(_safe_name_lower)
|
|
209
|
+
dst_name_lower = plan['dst_path'].map(_safe_name_lower)
|
|
210
|
+
plan['_is_copy_skip_file'] = (plan['kind'] == 'file') & (
|
|
211
|
+
src_name_lower.isin(copy_skip_files) | dst_name_lower.isin(copy_skip_files)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
copy_skip_any = plan['_under_copy_skip_dir'] | plan['_is_copy_skip_file']
|
|
215
|
+
|
|
216
|
+
# delete-skip by folder subtree / file name (for "extra in dst" deletes)
|
|
217
|
+
plan['_under_delete_skip_dir'] = rel_p.map(
|
|
218
|
+
lambda p: _is_under_folder_name(p, del_skip_dirs)
|
|
219
|
+
)
|
|
220
|
+
is_delete_skip_file = dst_name_lower.isin(del_skip_files)
|
|
221
|
+
|
|
222
|
+
# for try-delete names
|
|
223
|
+
is_try_file = dst_name_lower.isin(del_try_files)
|
|
224
|
+
rel_last_part_lower = rel_p.map(lambda p: p.parts[-1].lower() if p.parts else '')
|
|
225
|
+
is_try_dir = rel_last_part_lower.isin(del_try_dirs)
|
|
226
|
+
|
|
227
|
+
# initialize
|
|
228
|
+
plan['action'] = ''
|
|
229
|
+
plan['reason'] = ''
|
|
230
|
+
plan['action_order'] = 0
|
|
231
|
+
plan['_created_at'] = datetime.now().isoformat(timespec='seconds')
|
|
232
|
+
|
|
233
|
+
# -----------------------------
|
|
234
|
+
# 0) NEW RULE (your requirement):
|
|
235
|
+
# If in copy skip list and exists in destination -> DELETE in destination
|
|
236
|
+
# If in copy skip list and destination missing -> SKIP
|
|
237
|
+
# -----------------------------
|
|
238
|
+
|
|
239
|
+
delete_skip_any = plan['_under_delete_skip_dir'] | is_delete_skip_file
|
|
240
|
+
|
|
241
|
+
# file cleanup (copy-skip) BUT respect delete-skip
|
|
242
|
+
plan.loc[
|
|
243
|
+
copy_skip_any
|
|
244
|
+
& plan['dst_exists']
|
|
245
|
+
& (plan['kind'] == 'file')
|
|
246
|
+
& ~delete_skip_any
|
|
247
|
+
& (plan['action'] == ''),
|
|
248
|
+
['action', 'reason', 'action_order'],
|
|
249
|
+
] = ['delete', 'copy_skip_cleanup', 70]
|
|
250
|
+
|
|
251
|
+
# dir cleanup (copy-skip) BUT respect delete-skip
|
|
252
|
+
plan.loc[
|
|
253
|
+
copy_skip_any
|
|
254
|
+
& plan['dst_exists']
|
|
255
|
+
& (plan['kind'] == 'dir')
|
|
256
|
+
& ~delete_skip_any
|
|
257
|
+
& (plan['action'] == ''),
|
|
258
|
+
['action', 'reason', 'action_order'],
|
|
259
|
+
] = ['delete_dir', 'copy_skip_cleanup', 80]
|
|
260
|
+
|
|
261
|
+
# copy-skip but protected from delete => skip
|
|
262
|
+
plan.loc[
|
|
263
|
+
copy_skip_any & plan['dst_exists'] & delete_skip_any & (plan['action'] == ''),
|
|
264
|
+
['action', 'reason', 'action_order'],
|
|
265
|
+
] = ['skip', 'copy_skip_but_delete_skipped', 90]
|
|
266
|
+
|
|
267
|
+
# dir level skip when exists in src but not in dst (nothing to delete)
|
|
268
|
+
plan.loc[
|
|
269
|
+
~copy_skip_any
|
|
270
|
+
& plan['src_exists']
|
|
271
|
+
& ~plan['dst_exists']
|
|
272
|
+
& (plan['kind'] == 'dir')
|
|
273
|
+
& (plan['action'] == ''),
|
|
274
|
+
['action', 'reason', 'action_order'],
|
|
275
|
+
] = ['skip', 'dir_tree_skip', 95]
|
|
276
|
+
|
|
277
|
+
# -----------------------------
|
|
278
|
+
# 1) Normal copy/update (only undecided, only NOT copy-skip)
|
|
279
|
+
# -----------------------------
|
|
280
|
+
files_mask = (
|
|
281
|
+
(plan['kind'] == 'file')
|
|
282
|
+
& plan['src_exists']
|
|
283
|
+
& (plan['action'] == '')
|
|
284
|
+
& ~copy_skip_any
|
|
285
|
+
& ~(plan['src_excluded'] == True) # noqa: E712
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
copy_mask = files_mask & ~plan['dst_exists']
|
|
289
|
+
plan.loc[copy_mask, ['action', 'reason', 'action_order']] = [
|
|
290
|
+
'copy',
|
|
291
|
+
'missing_in_dst',
|
|
292
|
+
10,
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
update_candidates = files_mask & plan['dst_exists']
|
|
296
|
+
|
|
297
|
+
# FIX dtype warning
|
|
298
|
+
plan['_files_equal'] = pd.Series(pd.NA, index=plan.index, dtype='boolean')
|
|
299
|
+
|
|
300
|
+
eq_flags = [
|
|
301
|
+
_files_equal_by_stat(Path(sp), Path(dp))
|
|
302
|
+
for sp, dp in plan.loc[update_candidates, ['src_path', 'dst_path']].itertuples(
|
|
303
|
+
index=False
|
|
304
|
+
)
|
|
305
|
+
]
|
|
306
|
+
plan.loc[update_candidates, '_files_equal'] = pd.Series(
|
|
307
|
+
eq_flags, index=plan.index[update_candidates], dtype='boolean'
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
update_mask = update_candidates & (plan['_files_equal'] == False) # noqa: E712
|
|
311
|
+
plan.loc[update_mask, ['action', 'reason', 'action_order']] = [
|
|
312
|
+
'update',
|
|
313
|
+
'different',
|
|
314
|
+
20,
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
same_mask = update_candidates & (plan['_files_equal'] == True) # noqa: E712
|
|
318
|
+
plan.loc[same_mask, ['action', 'reason', 'action_order']] = ['skip', 'same', 90]
|
|
319
|
+
|
|
320
|
+
# -----------------------------
|
|
321
|
+
# 2) Delete extras in destination (dst exists, src missing), only undecided
|
|
322
|
+
# (copy-skip cleanup already handled above)
|
|
323
|
+
# -----------------------------
|
|
324
|
+
extra_in_dst = plan['dst_exists'] & ~plan['src_exists'] & (plan['action'] == '')
|
|
325
|
+
|
|
326
|
+
# delete-skip
|
|
327
|
+
delete_skip = extra_in_dst & (plan['_under_delete_skip_dir'] | is_delete_skip_file)
|
|
328
|
+
plan.loc[delete_skip, ['action', 'reason', 'action_order']] = [
|
|
329
|
+
'skip',
|
|
330
|
+
'delete_skip_rule',
|
|
331
|
+
90,
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
# delete files
|
|
335
|
+
del_file_candidates = (
|
|
336
|
+
extra_in_dst & (plan['kind'] == 'file') & (plan['action'] == '')
|
|
337
|
+
)
|
|
338
|
+
plan.loc[
|
|
339
|
+
del_file_candidates & is_try_file, ['action', 'reason', 'action_order']
|
|
340
|
+
] = ['delete_try', 'missing_in_src_try', 71]
|
|
341
|
+
plan.loc[
|
|
342
|
+
del_file_candidates & ~is_try_file, ['action', 'reason', 'action_order']
|
|
343
|
+
] = ['delete', 'missing_in_src', 70]
|
|
344
|
+
|
|
345
|
+
# delete dirs (bottom-up sorting later)
|
|
346
|
+
del_dir_candidates = extra_in_dst & (plan['kind'] == 'dir') & (plan['action'] == '')
|
|
347
|
+
plan.loc[del_dir_candidates & is_try_dir, ['action', 'reason', 'action_order']] = [
|
|
348
|
+
'delete_dir_try',
|
|
349
|
+
'missing_in_src_try',
|
|
350
|
+
81,
|
|
351
|
+
]
|
|
352
|
+
plan.loc[del_dir_candidates & ~is_try_dir, ['action', 'reason', 'action_order']] = [
|
|
353
|
+
'delete_dir',
|
|
354
|
+
'missing_in_src',
|
|
355
|
+
80,
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
# -----------------------------
|
|
359
|
+
# 3) Anything still undecided -> skip
|
|
360
|
+
# -----------------------------
|
|
361
|
+
plan.loc[plan['action'] == '', ['action', 'reason', 'action_order']] = [
|
|
362
|
+
'skip',
|
|
363
|
+
'no_rule_matched',
|
|
364
|
+
90,
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
# depth for bottom-up dir deletion
|
|
368
|
+
plan['_depth'] = rel_p.map(lambda p: len(p.parts))
|
|
369
|
+
|
|
370
|
+
# Execution order:
|
|
371
|
+
# copy (10), update (20), delete file (70/71), delete dir (80/81 deepest first), skip (90)
|
|
372
|
+
plan = plan.sort_values(
|
|
373
|
+
['dest_root', 'action_order', '_depth'], ascending=[True, True, False]
|
|
374
|
+
).reset_index(drop=True)
|
|
375
|
+
|
|
376
|
+
# cleanup helper columns (keep if you want to debug)
|
|
377
|
+
plan = plan.drop(
|
|
378
|
+
columns=[
|
|
379
|
+
'_under_copy_skip_dir',
|
|
380
|
+
'_is_copy_skip_file',
|
|
381
|
+
'_under_delete_skip_dir',
|
|
382
|
+
'_files_equal',
|
|
383
|
+
'_depth',
|
|
384
|
+
],
|
|
385
|
+
errors='ignore',
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return plan
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# =========================
|
|
392
|
+
# Execute plan with fact columns
|
|
393
|
+
# =========================
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def execute_plan(df: pd.DataFrame) -> tuple[pd.DataFrame, dict]:
|
|
397
|
+
df = df.copy()
|
|
398
|
+
|
|
399
|
+
# fact columns
|
|
400
|
+
df['result'] = (
|
|
401
|
+
'' # copied / updated / deleted_file / deleted_dir / skipped / already_missing / try_delete_failed / error
|
|
402
|
+
)
|
|
403
|
+
df['error'] = '' # exception text
|
|
404
|
+
|
|
405
|
+
summary = {
|
|
406
|
+
'copied_files': 0,
|
|
407
|
+
'updated_files': 0,
|
|
408
|
+
'deleted_files': 0,
|
|
409
|
+
'skipped_files': 0,
|
|
410
|
+
'deleted_dirs': 0,
|
|
411
|
+
'skipped_dirs': 0,
|
|
412
|
+
'try_delete_failed': 0,
|
|
413
|
+
'errors': 0,
|
|
414
|
+
'invalid_paths': 0,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
def _get_str(val) -> str | None:
|
|
418
|
+
if val is None or pd.isna(val):
|
|
419
|
+
return None
|
|
420
|
+
return str(val)
|
|
421
|
+
|
|
422
|
+
for i, row in df.iterrows():
|
|
423
|
+
action = row.get('action', '')
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
if action == 'skip':
|
|
427
|
+
if row.get('kind') == 'file':
|
|
428
|
+
summary['skipped_files'] += 1
|
|
429
|
+
elif row.get('kind') == 'dir':
|
|
430
|
+
summary['skipped_dirs'] += 1
|
|
431
|
+
df.at[i, 'result'] = 'skipped'
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
if action in ('copy', 'update'):
|
|
435
|
+
src_s = _get_str(row.get('src_path'))
|
|
436
|
+
dst_s = _get_str(row.get('dst_path'))
|
|
437
|
+
if not src_s:
|
|
438
|
+
summary['invalid_paths'] += 1
|
|
439
|
+
summary['errors'] += 1
|
|
440
|
+
df.at[i, 'result'] = 'error'
|
|
441
|
+
df.at[i, 'error'] = 'missing src_path'
|
|
442
|
+
continue
|
|
443
|
+
if not dst_s:
|
|
444
|
+
dest_root = _get_str(row.get('dest_root', '')) or ''
|
|
445
|
+
rel_path = _get_str(row.get('rel_path', '')) or ''
|
|
446
|
+
dst_s = os.path.join(dest_root, rel_path)
|
|
447
|
+
if not dst_s:
|
|
448
|
+
summary['invalid_paths'] += 1
|
|
449
|
+
summary['errors'] += 1
|
|
450
|
+
df.at[i, 'result'] = 'error'
|
|
451
|
+
df.at[i, 'error'] = 'missing dst_path'
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
src = Path(src_s)
|
|
455
|
+
dst = Path(dst_s)
|
|
456
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
457
|
+
shutil.copy2(src, dst)
|
|
458
|
+
|
|
459
|
+
if action == 'copy':
|
|
460
|
+
summary['copied_files'] += 1
|
|
461
|
+
df.at[i, 'result'] = 'copied'
|
|
462
|
+
# print(f'[COPY] {src} -> {dst}')
|
|
463
|
+
else:
|
|
464
|
+
summary['updated_files'] += 1
|
|
465
|
+
df.at[i, 'result'] = 'updated'
|
|
466
|
+
# print(f'[UPDATE] {src} -> {dst}')
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
if action in ('delete', 'delete_try'):
|
|
470
|
+
dst_s = _get_str(row.get('dst_path'))
|
|
471
|
+
if not dst_s:
|
|
472
|
+
summary['invalid_paths'] += 1
|
|
473
|
+
summary['errors'] += 1
|
|
474
|
+
df.at[i, 'result'] = 'error'
|
|
475
|
+
df.at[i, 'error'] = 'missing dst_path'
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
dst = Path(dst_s)
|
|
479
|
+
if not dst.exists():
|
|
480
|
+
df.at[i, 'result'] = 'already_missing'
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
dst.unlink()
|
|
485
|
+
summary['deleted_files'] += 1
|
|
486
|
+
df.at[i, 'result'] = 'deleted_file'
|
|
487
|
+
# print(f'[DELETE FILE] {dst}')
|
|
488
|
+
except Exception as e:
|
|
489
|
+
if action == 'delete_try':
|
|
490
|
+
summary['try_delete_failed'] += 1
|
|
491
|
+
df.at[i, 'result'] = 'try_delete_failed'
|
|
492
|
+
df.at[i, 'error'] = str(e)
|
|
493
|
+
# print(f'[TRY DELETE FILE - SKIPPED] {dst} ({e})')
|
|
494
|
+
else:
|
|
495
|
+
raise
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
if action in ('delete_dir', 'delete_dir_try'):
|
|
499
|
+
dst_s = _get_str(row.get('dst_path'))
|
|
500
|
+
if not dst_s:
|
|
501
|
+
summary['invalid_paths'] += 1
|
|
502
|
+
summary['errors'] += 1
|
|
503
|
+
df.at[i, 'result'] = 'error'
|
|
504
|
+
df.at[i, 'error'] = 'missing dst_path'
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
dst = Path(dst_s)
|
|
508
|
+
if not dst.exists():
|
|
509
|
+
df.at[i, 'result'] = 'already_missing'
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
shutil.rmtree(dst)
|
|
514
|
+
summary['deleted_dirs'] += 1
|
|
515
|
+
df.at[i, 'result'] = 'deleted_dir'
|
|
516
|
+
# print(f'[DELETE DIR] {dst}')
|
|
517
|
+
except Exception as e:
|
|
518
|
+
if action == 'delete_dir_try':
|
|
519
|
+
summary['try_delete_failed'] += 1
|
|
520
|
+
df.at[i, 'result'] = 'try_delete_failed'
|
|
521
|
+
df.at[i, 'error'] = str(e)
|
|
522
|
+
# print(f'[TRY DELETE DIR - SKIPPED] {dst} ({e})')
|
|
523
|
+
else:
|
|
524
|
+
raise
|
|
525
|
+
continue
|
|
526
|
+
|
|
527
|
+
# unknown action
|
|
528
|
+
summary['errors'] += 1
|
|
529
|
+
df.at[i, 'result'] = 'error'
|
|
530
|
+
df.at[i, 'error'] = f'Unknown action: {action}'
|
|
531
|
+
|
|
532
|
+
except Exception as e:
|
|
533
|
+
summary['errors'] += 1
|
|
534
|
+
df.at[i, 'result'] = 'error'
|
|
535
|
+
df.at[i, 'error'] = str(e)
|
|
536
|
+
# print(f'[ERROR] action={action} rel={row.get("rel_path")} err={e}')
|
|
537
|
+
|
|
538
|
+
return df, summary
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def log_folder_retention(log_folder: str, retain_days: int = 0) -> None:
|
|
542
|
+
"""Delete log files older than retain_days in the log_folder."""
|
|
543
|
+
cutoff_datetime = datetime.now() - pd.Timedelta(days=retain_days)
|
|
544
|
+
|
|
545
|
+
folder = Path(log_folder)
|
|
546
|
+
for pattern in ('sync_executed_*.csv', 'sync_plan_debug*.csv'):
|
|
547
|
+
for log_file in folder.glob(pattern):
|
|
548
|
+
try:
|
|
549
|
+
file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
|
|
550
|
+
if file_mtime < cutoff_datetime:
|
|
551
|
+
log_file.unlink()
|
|
552
|
+
logging_utils.info(f'Deleted old log file: {log_file}')
|
|
553
|
+
except Exception as e:
|
|
554
|
+
logging_utils.error(f'Error checking/deleting log file {log_file}: {e}')
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def log_activity(df: pd.DataFrame, summary: dict, log_folder: str) -> None:
|
|
558
|
+
"""Simple logger function to log activity messages with timestamps."""
|
|
559
|
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
560
|
+
folder = Path(log_folder)
|
|
561
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
562
|
+
df.to_csv(folder / f'sync_executed_{timestamp}.csv', index=False)
|
|
563
|
+
summary_msg = '\n===== Summary ====='
|
|
564
|
+
for k, v in summary.items():
|
|
565
|
+
summary_msg += f'\n{k:18}: {v}'
|
|
566
|
+
summary_msg += '\n==================='
|
|
567
|
+
logging_utils.info(summary_msg)
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .git_utils import (
|
|
2
|
+
commit_all_changes,
|
|
3
|
+
push_changes,
|
|
4
|
+
is_git_repo,
|
|
5
|
+
repo_root,
|
|
6
|
+
current_branch,
|
|
7
|
+
current_commit,
|
|
8
|
+
is_clean,
|
|
9
|
+
list_tags,
|
|
10
|
+
list_branches,
|
|
11
|
+
file_last_commit,
|
|
12
|
+
GitError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
'commit_all_changes',
|
|
17
|
+
'push_changes',
|
|
18
|
+
'is_git_repo',
|
|
19
|
+
'repo_root',
|
|
20
|
+
'current_branch',
|
|
21
|
+
'current_commit',
|
|
22
|
+
'is_clean',
|
|
23
|
+
'list_tags',
|
|
24
|
+
'list_branches',
|
|
25
|
+
'file_last_commit',
|
|
26
|
+
'GitError',
|
|
27
|
+
]
|