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.
Files changed (60) hide show
  1. al_data_utils/__init__.py +10 -0
  2. al_data_utils/airflow_utils/__init__.py +13 -0
  3. al_data_utils/airflow_utils/airflow_utils.py +83 -0
  4. al_data_utils/azure_as_utils/__init__.py +31 -0
  5. al_data_utils/azure_as_utils/auth.py +82 -0
  6. al_data_utils/azure_as_utils/azure_as_utils.py +223 -0
  7. al_data_utils/azure_as_utils/client.py +158 -0
  8. al_data_utils/azure_as_utils/errors.py +78 -0
  9. al_data_utils/azure_as_utils/models.py +265 -0
  10. al_data_utils/azure_as_utils/poller.py +121 -0
  11. al_data_utils/backup_utils/__init__.py +9 -0
  12. al_data_utils/backup_utils/mssql/dacpac/__init__.py +1 -0
  13. al_data_utils/backup_utils/mssql/dacpac/sqlpackage.py +207 -0
  14. al_data_utils/backup_utils/mssql/schema_snapshot/__init__.py +8 -0
  15. al_data_utils/backup_utils/mssql/schema_snapshot/config.py +150 -0
  16. al_data_utils/backup_utils/mssql/schema_snapshot/emit/__init__.py +11 -0
  17. al_data_utils/backup_utils/mssql/schema_snapshot/emit/foreign_keys.py +38 -0
  18. al_data_utils/backup_utils/mssql/schema_snapshot/emit/indexes.py +37 -0
  19. al_data_utils/backup_utils/mssql/schema_snapshot/emit/sql_objects.py +17 -0
  20. al_data_utils/backup_utils/mssql/schema_snapshot/emit/tables.py +139 -0
  21. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/__init__.py +19 -0
  22. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/foreign_keys.py +173 -0
  23. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/functions.py +133 -0
  24. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/indexes.py +190 -0
  25. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/sequences.py +180 -0
  26. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/stored_procedures.py +313 -0
  27. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/synonyms.py +135 -0
  28. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/tables.py +251 -0
  29. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/views.py +244 -0
  30. al_data_utils/backup_utils/mssql/schema_snapshot/io/__init__.py +10 -0
  31. al_data_utils/backup_utils/mssql/schema_snapshot/io/layout.py +28 -0
  32. al_data_utils/backup_utils/mssql/schema_snapshot/io/writer.py +40 -0
  33. al_data_utils/backup_utils/mssql/schema_snapshot/models/__init__.py +14 -0
  34. al_data_utils/backup_utils/mssql/schema_snapshot/models/foreign_key_def.py +19 -0
  35. al_data_utils/backup_utils/mssql/schema_snapshot/models/index_def.py +19 -0
  36. al_data_utils/backup_utils/mssql/schema_snapshot/models/sql_object_def.py +11 -0
  37. al_data_utils/backup_utils/mssql/schema_snapshot/models/table_column_def.py +27 -0
  38. al_data_utils/backup_utils/mssql/schema_snapshot/models/table_def.py +22 -0
  39. al_data_utils/backup_utils/mssql/schema_snapshot/snapshot_processor.py +754 -0
  40. al_data_utils/backup_utils/mssql/scripter/__init__.py +1 -0
  41. al_data_utils/config_utils/__init__.py +6 -0
  42. al_data_utils/config_utils/config_utils.py +31 -0
  43. al_data_utils/files_sync_utils/__init__.py +15 -0
  44. al_data_utils/files_sync_utils/files_sync_utils.py +567 -0
  45. al_data_utils/geo_utils/__init__.py +0 -0
  46. al_data_utils/git_utils/__init__.py +27 -0
  47. al_data_utils/git_utils/git_utils.py +142 -0
  48. al_data_utils/logging_utils/__init__.py +3 -0
  49. al_data_utils/logging_utils/logging_utils.py +39 -0
  50. al_data_utils/ones_api_utils/__init__.py +3 -0
  51. al_data_utils/ones_api_utils/ones_api_utils.py +61 -0
  52. al_data_utils/sql_utils/__init__.py +3 -0
  53. al_data_utils/sql_utils/sql_utils.py +158 -0
  54. al_data_utils/tg_utils/__init__.py +3 -0
  55. al_data_utils/tg_utils/tg_utils.py +45 -0
  56. al_data_utils-0.3.1.dist-info/METADATA +157 -0
  57. al_data_utils-0.3.1.dist-info/RECORD +60 -0
  58. al_data_utils-0.3.1.dist-info/WHEEL +5 -0
  59. al_data_utils-0.3.1.dist-info/licenses/LICENSE +21 -0
  60. 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,6 @@
1
+ from .config_utils import (
2
+ load_config,
3
+ save_config
4
+ )
5
+
6
+ __all__ = ['load_config', 'save_config']
@@ -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,15 @@
1
+ from .files_sync_utils import (
2
+ build_plan_df,
3
+ log_activity,
4
+ log_folder_retention,
5
+ SyncConfig,
6
+ execute_plan,
7
+ )
8
+
9
+ __all__ = [
10
+ 'build_plan_df',
11
+ 'log_activity',
12
+ 'log_folder_retention',
13
+ 'SyncConfig',
14
+ 'execute_plan',
15
+ ]
@@ -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
+ ]