trops 0.2.34__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.
trops/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ import sys
2
+
3
+ # Minimal import-time checks only. Heavyweight runtime checks should live in command handlers.
4
+ required_python_major = 3
5
+ required_python_minor = 8
6
+ if sys.version_info < (required_python_major, required_python_minor):
7
+ raise SystemExit(
8
+ f"ERROR: This program requires Python {required_python_major}.{required_python_minor} or newer. "
9
+ f"Current version: {'.'.join(map(str, sys.version_info[:3]))}"
10
+ )
trops/capcmd.py ADDED
@@ -0,0 +1,376 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import List, Tuple
8
+ from configparser import ConfigParser
9
+
10
+ from .trops import TropsBase, TropsError
11
+ import re
12
+ from .utils import absolute_path
13
+
14
+
15
+ class TropsCapCmd(TropsBase):
16
+ """Trops Capture Command class"""
17
+
18
+ def __init__(self, args, other_args):
19
+ # Enforce TROPS_DIR for capture-cmd only (per project requirement)
20
+ if 'TROPS_DIR' not in os.environ:
21
+ raise TropsError('ERROR: The TROPS_DIR environment variable has not been set.')
22
+ super().__init__(args, other_args)
23
+
24
+ # If TROPS_ENV is specified but missing in config, error out early with a clear message
25
+ conf_path = os.path.join(self.trops_dir, 'trops.cfg')
26
+ if self.trops_env and os.path.isfile(conf_path):
27
+ config = ConfigParser()
28
+ config.read(conf_path)
29
+ if not config.has_section(self.trops_env):
30
+ raise TropsError(f"ERROR: TROPS_ENV '{self.trops_env}' does not exist in your configuration at {conf_path}.")
31
+
32
+ # Ensure attributes exist even when no config section is present
33
+ # This avoids AttributeError later and provides sane defaults
34
+ if not hasattr(self, 'ignore_cmds'):
35
+ self.ignore_cmds = {'ttags'}
36
+ if not hasattr(self, 'disable_header'):
37
+ self.disable_header = False
38
+
39
+ # Start setting the header with stable positions: trops|env|sid|tags
40
+ header_env = getattr(self, 'trops_env', '') or ''
41
+ header_sid = getattr(self, 'trops_sid', '') or ''
42
+ header_tags = getattr(self, 'trops_tags', '') or ''
43
+ self.trops_header = ['trops', header_env, header_sid, header_tags]
44
+ # Defer FL logs until after command logging to preserve real-world order
45
+ self._defer_file_logs = False
46
+ self._deferred_file_logs = []
47
+
48
+ def _flush_deferred_file_logs(self) -> None:
49
+ """Flush and clear any deferred file logs."""
50
+ if getattr(self, '_deferred_file_logs', None):
51
+ for fl_message in self._deferred_file_logs:
52
+ self.logger.info(fl_message)
53
+ self._defer_file_logs = False
54
+ self._deferred_file_logs = []
55
+
56
+ def capture_cmd(self) -> None:
57
+ """Capture and log the executed command"""
58
+
59
+ return_code = self.args.return_code
60
+ now_hm = datetime.now().strftime("%H-%M")
61
+
62
+ # Enable deferring of file logs that may be produced by pre-processing
63
+ self._defer_file_logs = True
64
+ self._deferred_file_logs = []
65
+
66
+ if not self.other_args:
67
+ # No command to log; flush any deferred logs then exit
68
+ self._flush_deferred_file_logs()
69
+ self.print_header()
70
+ sys.exit(0)
71
+
72
+ executed_cmd = self.other_args
73
+ time_and_cmd = f"{now_hm} {' '.join(executed_cmd)}"
74
+
75
+ # Fast-path: skip early if command is in ignore list (performance)
76
+ sanitized_for_ignore = self._sanitize_for_sudo(executed_cmd)
77
+ if self.ignore_cmds and sanitized_for_ignore and sanitized_for_ignore[0] in self.ignore_cmds:
78
+ # Ignored command; flush deferred logs to preserve previous behavior
79
+ self._flush_deferred_file_logs()
80
+ self.print_header()
81
+ sys.exit(0)
82
+
83
+ # Ensure tmp directory exists
84
+ tmp_dir = Path(self.trops_dir) / 'tmp'
85
+ tmp_dir.mkdir(parents=True, exist_ok=True)
86
+ last_cmd_path = tmp_dir / 'last_cmd'
87
+
88
+ # Side-effect operations that should happen even if the command is repeated
89
+ # 1) Track files edited by common editors
90
+ self._track_editor_files(executed_cmd)
91
+ # 2) Track files written via tee
92
+ wrote_with_tee = self._add_tee_output_file(executed_cmd)
93
+ # 3) Try pushing if remote is configured and we actually added/updated files
94
+ if wrote_with_tee:
95
+ self._push_if_remote_set()
96
+
97
+ # Skip if repeated within the same minute (after performing file updates)
98
+ if self._is_repeat_command(str(last_cmd_path), time_and_cmd):
99
+ if not self.disable_header:
100
+ # Repeated command; flush deferred logs to preserve previous behavior
101
+ self._flush_deferred_file_logs()
102
+ self.print_header()
103
+ sys.exit(0)
104
+
105
+ # Save last command signature
106
+ self._save_last_command(str(last_cmd_path), time_and_cmd)
107
+
108
+ # (kept for clarity; normally unreachable due to early fast-path above)
109
+ sanitized_for_ignore = self._sanitize_for_sudo(executed_cmd)
110
+ if self.ignore_cmds and sanitized_for_ignore and sanitized_for_ignore[0] in self.ignore_cmds:
111
+ # Ignored command; flush deferred logs to preserve previous behavior
112
+ self._flush_deferred_file_logs()
113
+ self.print_header()
114
+ sys.exit(0)
115
+
116
+ # Log command message
117
+ message = self._compose_capture_message(executed_cmd, return_code)
118
+ if return_code == 0:
119
+ self.logger.info(message)
120
+ else:
121
+ self.logger.warning(message)
122
+
123
+ # Flush any deferred file logs after the command has been logged
124
+ self._flush_deferred_file_logs()
125
+
126
+ if not self.disable_header:
127
+ self.print_header()
128
+
129
+ def _compose_capture_message(self, executed_cmd: List[str], return_code: int) -> str:
130
+ parts: List[str] = [
131
+ f"CM {' '.join(executed_cmd)} #> PWD={os.getenv('PWD')}",
132
+ f"EXIT={return_code}",
133
+ ]
134
+ if self.trops_sid:
135
+ parts.append(f"TROPS_SID={self.trops_sid}")
136
+ if self.trops_env:
137
+ parts.append(f"TROPS_ENV={self.trops_env}")
138
+ if self.trops_tags:
139
+ parts.append(f"TROPS_TAGS={self.trops_tags}")
140
+ return ', '.join(parts)
141
+
142
+ def _is_repeat_command(self, last_cmd_path, time_and_cmd):
143
+ """Check if the current command is a repeat of the last command"""
144
+ if os.path.isfile(last_cmd_path):
145
+ with open(last_cmd_path, 'r') as f:
146
+ return time_and_cmd == f.read()
147
+ return False
148
+
149
+ def _save_last_command(self, last_cmd_path, time_and_cmd):
150
+ """Save the current command as the last executed command"""
151
+ with open(last_cmd_path, 'w') as f:
152
+ f.write(time_and_cmd)
153
+
154
+ def print_header(self):
155
+ # Print -= trops|env|sid|tags =-
156
+ print(f'\n-= {"|".join(self.trops_header)} =-')
157
+
158
+ def _yum_log(self, executed_cmd: List[str]) -> None:
159
+
160
+ # Check if sudo is used
161
+ executed_cmd = executed_cmd[1:] if executed_cmd[0] == 'sudo' else executed_cmd
162
+
163
+ if executed_cmd[0] in ['yum', 'dnf'] and any(x in executed_cmd for x in ['install', 'update', 'remove']):
164
+ cmd = ['rpm', '-qa']
165
+ result = subprocess.run(cmd, capture_output=True, check=True)
166
+ pkg_list = result.stdout.decode('utf-8').splitlines()
167
+ pkg_list.sort()
168
+
169
+ pkg_list_file = os.path.join(self.trops_dir, f'log/rpm_pkg_list.{self.hostname}')
170
+ with open(pkg_list_file, 'w') as f:
171
+ f.write('\n'.join(pkg_list))
172
+
173
+ self.add_and_commit_file(pkg_list_file)
174
+
175
+ def _apt_log(self, executed_cmd: List[str]) -> None:
176
+ if 'apt' in executed_cmd and any(x in executed_cmd for x in ['upgrade', 'install', 'update', 'remove', 'autoremove']):
177
+ self._update_pkg_list(' '.join(executed_cmd))
178
+ # TODO: Add log trops git show hex
179
+
180
+ def _update_pkg_list(self, args: str) -> None:
181
+
182
+ # Update the pkg_List
183
+ cmd = ['apt', 'list', '--installed']
184
+ result = subprocess.run(cmd, capture_output=True)
185
+ pkg_list = result.stdout.decode('utf-8').splitlines()
186
+ pkg_list.sort()
187
+
188
+ pkg_list_file = self.trops_dir + \
189
+ f'/log/apt_pkg_list.{ self.hostname }'
190
+ with open(pkg_list_file, 'w') as f:
191
+ f.write('\n'.join(pkg_list))
192
+
193
+ self.add_and_commit_file(pkg_list_file)
194
+
195
+ def _add_file_in_git_repo(self, executed_cmd: List[str], start_index: int, first_line_comment: str = None) -> None:
196
+ for file_arg in executed_cmd[start_index:]:
197
+ file_path = absolute_path(file_arg)
198
+ if not os.path.isfile(file_path):
199
+ continue
200
+ # Optionally prepend a comment line describing the source command (for tee outputs)
201
+ if first_line_comment:
202
+ try:
203
+ with open(file_path, 'r+', encoding='utf-8', errors='ignore') as f:
204
+ content = f.read()
205
+ if not content.startswith(first_line_comment):
206
+ f.seek(0)
207
+ f.write(first_line_comment + "\n" + content)
208
+ except Exception:
209
+ try:
210
+ with open(file_path, 'w', encoding='utf-8', errors='ignore') as f:
211
+ f.write(first_line_comment + "\n")
212
+ except Exception:
213
+ pass
214
+ # Ignore if path is already tracked in another repo
215
+ if file_is_in_a_git_repo(file_path):
216
+ self.logger.info(
217
+ f"FL {file_path} is under a git repository #> PWD=*, EXIT=*, TROPS_SID={self.trops_sid}, TROPS_ENV={self.trops_env}")
218
+ sys.exit(0)
219
+ git_msg, log_note = self._generate_git_msg_and_log_note(file_path)
220
+ result = self._add_and_commit_file(file_path, git_msg)
221
+ if result.returncode == 0:
222
+ msg = result.stdout.decode('utf-8').splitlines()[0]
223
+ print(msg)
224
+ self._add_file_log(file_path, log_note)
225
+ # Push immediately after a successful commit if remote is set
226
+ self._push_if_remote_set()
227
+ else:
228
+ print('No update')
229
+
230
+ def _add_file_log(self, file_path: str, log_note: str) -> None:
231
+ """Add an FL log entry"""
232
+ rel_path = os.path.relpath(os.path.realpath(absolute_path(file_path)), start=os.path.realpath(self.work_tree))
233
+ cmd = self.git_cmd + ['log', '--oneline', '-1', rel_path]
234
+ output = subprocess.check_output(
235
+ cmd).decode("utf-8").split()
236
+ if rel_path in output:
237
+ mode = oct(os.stat(file_path).st_mode)[-4:]
238
+ owner = Path(file_path).owner()
239
+ group = Path(file_path).group()
240
+ message = f"FL trops show { output[0] }:{ rel_path } #> { log_note }, O={ owner },G={ group },M={ mode }"
241
+ if self.trops_sid:
242
+ message += f" TROPS_SID={ self.trops_sid }"
243
+ message += f" TROPS_ENV={ self.trops_env }"
244
+ if self.trops_tags:
245
+ message += f" TROPS_TAGS={self.trops_tags}"
246
+ # Defer logging if requested so that command log comes first
247
+ if getattr(self, '_defer_file_logs', False):
248
+ self._deferred_file_logs.append(message)
249
+ else:
250
+ self.logger.info(message)
251
+
252
+ def _add_and_commit_file(self, file_path: str, git_msg: str) -> subprocess.CompletedProcess:
253
+ """Add a file in the git repo and commit if changed"""
254
+ rel_path = os.path.relpath(os.path.realpath(absolute_path(file_path)), start=os.path.realpath(self.work_tree))
255
+ subprocess.run(self.git_cmd + ['add', rel_path], capture_output=True)
256
+ return subprocess.run(self.git_cmd + ['commit', '-m', git_msg, rel_path], capture_output=True)
257
+
258
+ def _generate_git_msg_and_log_note(self, file_path: str) -> Tuple[str, str]:
259
+ """Generate the git commit message and log note"""
260
+ rel_path = os.path.relpath(os.path.realpath(absolute_path(file_path)), start=os.path.realpath(self.work_tree))
261
+ result = subprocess.run(self.git_cmd + ['ls-files', rel_path], capture_output=True)
262
+ is_tracked = bool(result.stdout.decode('utf-8'))
263
+ git_msg = f"{'Update' if is_tracked else 'Add'} {rel_path}"
264
+ log_note = 'UPDATE' if is_tracked else 'ADD'
265
+ if self.trops_tags:
266
+ git_msg = f"{git_msg} ({self.trops_tags})"
267
+ return git_msg, log_note
268
+
269
+ def _track_editor_files(self, executed_cmd: List[str]) -> None:
270
+ """Detect common editors and add the edited file(s) to the repo if present."""
271
+
272
+ # Remove sudo from executed_cmd (basic case)
273
+ executed_cmd = self._sanitize_for_sudo(executed_cmd)
274
+
275
+ # Check if editor is launched
276
+ editors = ['vim', 'vi', 'nvim', 'emacs', 'nano']
277
+ if executed_cmd[0] in editors:
278
+ # Add the edited file in trops git
279
+ self._add_file_in_git_repo(executed_cmd, 1)
280
+
281
+ def _add_tee_output_file(self, executed_cmd: List[str]) -> bool:
282
+ """Detect tee after one or more pipes and add the target file(s).
283
+
284
+ Supported forms:
285
+ - cmd | tee path/to/file
286
+ - cmd |tee path/to/file
287
+ - cmd1 | cmd2 | ... | tee path/to/file
288
+ """
289
+ # First normalize tokens so that every '|' is its own token
290
+ normalized: List[str] = []
291
+ for tok in executed_cmd:
292
+ if '|' in tok and tok != '|':
293
+ parts = re.split(r'(\|)', tok)
294
+ normalized.extend([p for p in parts if p])
295
+ else:
296
+ normalized.append(tok)
297
+
298
+ # Scan for the last occurrence of '|' followed by 'tee'
299
+ last_pipe_index = -1
300
+ for i in range(len(normalized) - 1):
301
+ if normalized[i] == '|' and normalized[i + 1] == 'tee':
302
+ last_pipe_index = i # index of '|' just before tee
303
+
304
+ if last_pipe_index != -1:
305
+ # Determine the left-hand command (before the last pipe that precedes tee)
306
+ left_cmd_tokens = normalized[:last_pipe_index]
307
+ left_cmd = ' '.join(left_cmd_tokens).strip()
308
+ comment = f"# {left_cmd}" if left_cmd else None
309
+ # Start collecting path arguments after 'tee'
310
+ tee_index = last_pipe_index + 1 # 'tee'
311
+ self._add_file_in_git_repo(normalized, tee_index + 1, first_line_comment=comment)
312
+ return True
313
+ return False
314
+
315
+ def _sanitize_for_sudo(self, executed_cmd: List[str]) -> List[str]:
316
+ """Remove leading sudo if present. TODO: handle sudo options."""
317
+ if executed_cmd and executed_cmd[0] == 'sudo':
318
+ return executed_cmd[1:]
319
+ return executed_cmd
320
+
321
+ def _push_if_remote_set(self) -> None:
322
+ """Push current branch if a git remote is configured.
323
+
324
+ This is a no-op when:
325
+ - no git_remote is configured
326
+ - git_dir is missing or not a valid directory
327
+ - git config file does not exist
328
+ """
329
+ if not getattr(self, 'git_remote', False):
330
+ return
331
+ if not hasattr(self, 'git_dir') or not isinstance(self.git_dir, str):
332
+ return
333
+ if not os.path.isdir(self.git_dir):
334
+ return
335
+ git_config_path = os.path.join(self.git_dir, 'config')
336
+ if not os.path.isfile(git_config_path):
337
+ return
338
+
339
+ # Determine current branch
340
+ result = subprocess.run(self.git_cmd + ['branch', '--show-current'], capture_output=True)
341
+ current_branch = result.stdout.decode('utf-8').strip() if result.returncode == 0 else ''
342
+ if not current_branch:
343
+ return
344
+
345
+ git_conf = ConfigParser()
346
+ git_conf.read(git_config_path)
347
+
348
+ # Ensure origin exists
349
+ if not git_conf.has_option('remote "origin"', 'url'):
350
+ subprocess.call(self.git_cmd + ['remote', 'add', 'origin', self.git_remote])
351
+
352
+ # Set upstream if missing, else regular push
353
+ if not git_conf.has_option(f'branch "{current_branch}"', 'remote'):
354
+ cmd = self.git_cmd + ['push', '--set-upstream', 'origin', current_branch]
355
+ else:
356
+ cmd = self.git_cmd + ['push']
357
+ subprocess.call(cmd)
358
+
359
+ def capture_cmd(args, other_args):
360
+
361
+ tc = TropsCapCmd(args, other_args)
362
+ tc.capture_cmd()
363
+
364
+ def add_capture_cmd_subparsers(subparsers):
365
+
366
+ parser_capture_cmd = subparsers.add_parser(
367
+ 'capture-cmd', help='Capture command line strings', add_help=False)
368
+ parser_capture_cmd.add_argument(
369
+ 'return_code', type=int, help='return code')
370
+ parser_capture_cmd.set_defaults(handler=capture_cmd)
371
+
372
+ def file_is_in_a_git_repo(file_path: str) -> bool:
373
+ parent_dir = os.path.dirname(file_path) or '.'
374
+ # Use git -C to avoid changing global working directory
375
+ result = subprocess.run(['git', '-C', parent_dir, 'rev-parse', '--is-inside-work-tree'], capture_output=True)
376
+ return result.returncode == 0