machineconfig 2.7__py3-none-any.whl → 2.9__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.

Potentially problematic release.


This version of machineconfig might be problematic. Click here for more details.

Files changed (30) hide show
  1. machineconfig/jobs/python/check_installations.py +0 -2
  2. machineconfig/jobs/python/python_ve_symlink.py +1 -1
  3. machineconfig/scripts/python/__pycache__/devops.cpython-313.pyc +0 -0
  4. machineconfig/scripts/python/__pycache__/devops_update_repos.cpython-313.pyc +0 -0
  5. machineconfig/scripts/python/__pycache__/fire_agents.cpython-313.pyc +0 -0
  6. machineconfig/scripts/python/__pycache__/repos_helper_record.cpython-313.pyc +0 -0
  7. machineconfig/scripts/python/devops.py +18 -64
  8. machineconfig/scripts/python/devops_add_identity.py +6 -2
  9. machineconfig/scripts/python/devops_add_ssh_key.py +5 -2
  10. machineconfig/scripts/python/devops_backup_retrieve.py +3 -15
  11. machineconfig/scripts/python/devops_devapps_install.py +22 -8
  12. machineconfig/scripts/python/devops_update_repos.py +125 -226
  13. machineconfig/scripts/python/fire_agents.py +108 -151
  14. machineconfig/scripts/python/fire_agents_help_launch.py +97 -0
  15. machineconfig/scripts/python/fire_agents_help_search.py +83 -0
  16. machineconfig/scripts/python/helpers/cloud_helpers.py +2 -5
  17. machineconfig/scripts/python/repos.py +1 -1
  18. machineconfig/scripts/python/repos_helper_record.py +82 -5
  19. machineconfig/scripts/python/repos_helper_update.py +288 -0
  20. machineconfig/utils/installer_utils/installer_class.py +3 -3
  21. machineconfig/utils/notifications.py +24 -4
  22. machineconfig/utils/path.py +2 -1
  23. machineconfig/utils/procs.py +50 -35
  24. machineconfig/utils/source_of_truth.py +2 -0
  25. machineconfig/utils/ssh.py +29 -7
  26. {machineconfig-2.7.dist-info → machineconfig-2.9.dist-info}/METADATA +7 -11
  27. {machineconfig-2.7.dist-info → machineconfig-2.9.dist-info}/RECORD +30 -26
  28. {machineconfig-2.7.dist-info → machineconfig-2.9.dist-info}/WHEEL +0 -0
  29. {machineconfig-2.7.dist-info → machineconfig-2.9.dist-info}/entry_points.txt +0 -0
  30. {machineconfig-2.7.dist-info → machineconfig-2.9.dist-info}/top_level.txt +0 -0
@@ -10,20 +10,25 @@ Improved design notes:
10
10
  from __future__ import annotations
11
11
 
12
12
  from pathlib import Path
13
- import shlex
14
13
  from math import ceil
15
14
  from typing import Literal, TypeAlias, get_args, Iterable
16
15
 
17
16
  from machineconfig.cluster.sessions_managers.zellij_local_manager import ZellijLocalManager
18
- from machineconfig.utils.schemas.layouts.layout_types import TabConfig, LayoutConfig
19
- from machineconfig.utils.utils2 import randstr
20
- import random
17
+ from machineconfig.scripts.python.fire_agents_help_launch import launch_agents
18
+ from machineconfig.scripts.python.fire_agents_help_search import search_files_by_pattern, search_python_files
19
+ from machineconfig.utils.schemas.layouts.layout_types import LayoutConfig
21
20
  # import time
22
21
 
23
22
  AGENTS: TypeAlias = Literal[
24
23
  "cursor-agent", "gemini", "crush", "q", "onlyPrepPromptFiles"
25
24
  # warp terminal
26
25
  ]
26
+
27
+ SPLITTING_STRATEGY: TypeAlias = Literal[
28
+ "agent_cap", # User decides number of agents, rows/tasks determined automatically
29
+ "task_rows" # User decides number of rows/tasks, number of agents determined automatically
30
+ ]
31
+
27
32
  DEFAULT_AGENT_CAP = 6
28
33
 
29
34
 
@@ -43,153 +48,86 @@ def get_gemini_api_keys() -> list[str]:
43
48
  return res
44
49
 
45
50
 
46
- def _search_python_files(repo_root: Path, keyword: str) -> list[Path]:
47
- """Return all Python files under repo_root whose text contains keyword.
48
-
49
- Notes:
50
- - Skips any paths that reside under a directory named ".venv" at any depth.
51
- - Errors reading individual files are ignored (decoded with 'ignore').
52
- """
53
- py_files = list(repo_root.rglob("*.py"))
54
- keyword_lower = keyword.lower()
55
- matches: list[Path] = []
56
- for f in py_files:
57
- # Skip anything under a .venv directory anywhere in the path
58
- if any(part == ".venv" for part in f.parts):
59
- continue
60
- try:
61
- if keyword_lower in f.read_text(encoding="utf-8", errors="ignore").lower():
62
- matches.append(f)
63
- except OSError:
64
- # Skip unreadable file
65
- continue
66
- return matches
67
-
68
-
69
51
  def _write_list_file(target: Path, files: Iterable[Path]) -> None:
70
52
  target.parent.mkdir(parents=True, exist_ok=True)
71
53
  target.write_text("\n".join(str(f) for f in files), encoding="utf-8")
72
54
 
73
55
 
74
- def _chunk_prompts(prompts: list[str], max_agents: int) -> list[str]:
75
- prompts = [p for p in prompts if p.strip() != ""] # drop blank entries
76
- if len(prompts) <= max_agents:
77
- return prompts
78
- print(f"Chunking {len(prompts)} prompts into groups for up to {max_agents} agents because it exceeds the cap.")
79
- chunk_size = ceil(len(prompts) / max_agents)
80
- grouped: list[str] = []
81
- for i in range(0, len(prompts), chunk_size):
82
- grouped.append("\nTargeted Locations:\n".join(prompts[i : i + chunk_size]))
83
- return grouped
84
-
85
-
86
- def _confirm(message: str, default_no: bool = True) -> bool:
87
- suffix = "[y/N]" if default_no else "[Y/n]"
88
- answer = input(f"{message} {suffix} ").strip().lower()
89
- if answer in {"y", "yes"}:
90
- return True
91
- if not default_no and answer == "":
92
- return True
93
- return False
94
-
95
-
96
- def launch_agents(repo_root: Path, prompts: list[str], agent: AGENTS, *, max_agents: int = DEFAULT_AGENT_CAP) -> list[TabConfig]:
97
- """Create tab configuration for a set of agent prompts.
98
-
99
- If number of prompts exceeds max_agents, ask user for confirmation.
100
- (Original behavior raised an error; now interactive override.)
56
+ def _chunk_prompts(prompts: list[str], strategy: SPLITTING_STRATEGY, *, agent_cap: int | None, task_rows: int | None) -> list[str]:
57
+ """Chunk prompts based on splitting strategy.
58
+
59
+ Args:
60
+ prompts: List of prompts to chunk
61
+ strategy: Either 'agent_cap' or 'task_rows'
62
+ agent_cap: Maximum number of agents (used with 'agent_cap' strategy)
63
+ task_rows: Number of rows/tasks per agent (used with 'task_rows' strategy)
101
64
  """
102
- if not prompts:
103
- raise ValueError("No prompts provided")
104
-
105
- if len(prompts) > max_agents:
106
- proceed = _confirm(message=(f"You are about to launch {len(prompts)} agents which exceeds the cap ({max_agents}). Proceed?"))
107
- if not proceed:
108
- print("Aborting per user choice.")
109
- return []
110
-
111
- tab_config: list[TabConfig] = []
112
- tmp_dir = repo_root / ".ai" / f"tmp_prompts/{randstr()}"
113
- tmp_dir.mkdir(parents=True, exist_ok=True)
114
-
115
- for idx, a_prompt in enumerate(prompts):
116
- prompt_path = tmp_dir / f"agent{idx}_prompt.txt"
117
- prompt_path.write_text(a_prompt, encoding="utf-8")
118
- cmd_path = tmp_dir / f"agent{idx}_cmd.sh"
119
- match agent:
120
- case "gemini":
121
- # model = "gemini-2.5-pro"
122
- # model = "gemini-2.5-flash-lite"
123
- model = None # auto-select
124
- if model is None:
125
- model_arg = ""
126
- else:
127
- model_arg = f"--model {shlex.quote(model)}"
128
- # Need a real shell for the pipeline; otherwise '| gemini ...' is passed as args to 'cat'
129
- safe_path = shlex.quote(str(prompt_path))
130
- api_keys = get_gemini_api_keys()
131
- api_key = api_keys[idx % len(api_keys)] if api_keys else ""
132
- # Export the environment variable so it's available to subshells
133
- cmd = f"""
134
- export GEMINI_API_KEY={shlex.quote(api_key)}
135
- echo "Using Gemini API key $GEMINI_API_KEY"
136
- cat {prompt_path}
137
- GEMINI_API_KEY={shlex.quote(api_key)} bash -lc 'cat {safe_path} | gemini {model_arg} --yolo --prompt'
138
- """
139
- case "cursor-agent":
140
- # As originally implemented
141
- cmd = f"""
142
-
143
- cursor-agent --print --output-format text < {prompt_path}
144
-
145
- """
146
- case "crush":
147
- cmd = f"""
148
- # cat {prompt_path} | crush run
149
- crush run {prompt_path}
150
- """
151
- case "q":
152
- cmd = f"""
153
- q chat --no-interactive --trust-all-tools {prompt_path}
154
- """
155
- case "onlyPrepPromptFiles":
156
- cmd = f"""
157
- echo "Prepared prompt file at {prompt_path}"
158
- """
159
- case _:
160
- raise ValueError(f"Unsupported agent type: {agent}")
161
- random_sleep_time = random.uniform(0, 5)
162
- cmd_prefix = f"""
163
- echo "Sleeping for {random_sleep_time:.2f} seconds to stagger agent startups..."
164
- sleep {random_sleep_time:.2f}
165
- echo "Launching `{agent}` with prompt from {prompt_path}"
166
- echo "Launching `{agent}` with command from {cmd_path}"
167
- echo "--------START OF AGENT OUTPUT--------"
168
- sleep 0.1
169
- """
170
- cmd_postfix = """
171
- sleep 0.1
172
- echo "---------END OF AGENT OUTPUT---------"
173
- """
174
- cmd_path.write_text(cmd_prefix + cmd + cmd_postfix, encoding="utf-8")
175
- fire_cmd = f"bash {shlex.quote(str(cmd_path))}"
176
- tab_config.append(TabConfig(tabName=f"Agent{idx}", startDir=str(repo_root), command=fire_cmd))
65
+ prompts = [p for p in prompts if p.strip() != ""] # drop blank entries
66
+
67
+ if strategy == "agent_cap":
68
+ if agent_cap is None:
69
+ raise ValueError("agent_cap must be provided when using 'agent_cap' strategy")
70
+
71
+ if len(prompts) <= agent_cap:
72
+ return prompts
73
+
74
+ print(f"Chunking {len(prompts)} prompts into groups for up to {agent_cap} agents because it exceeds the cap.")
75
+ chunk_size = ceil(len(prompts) / agent_cap)
76
+ grouped: list[str] = []
77
+ for i in range(0, len(prompts), chunk_size):
78
+ grouped.append("\nTargeted Locations:\n".join(prompts[i : i + chunk_size]))
79
+ return grouped
80
+
81
+ elif strategy == "task_rows":
82
+ if task_rows is None:
83
+ raise ValueError("task_rows must be provided when using 'task_rows' strategy")
84
+
85
+ if task_rows >= len(prompts):
86
+ return prompts
87
+
88
+ print(f"Chunking {len(prompts)} prompts into groups of {task_rows} rows/tasks each.")
89
+ grouped: list[str] = []
90
+ for i in range(0, len(prompts), task_rows):
91
+ grouped.append("\nTargeted Locations:\n".join(prompts[i : i + task_rows]))
92
+ return grouped
93
+
94
+ else:
95
+ raise ValueError(f"Unknown splitting strategy: {strategy}")
177
96
 
178
- print(f"Launching a template with #{len(tab_config)} agents")
179
- return tab_config
180
97
 
181
98
 
182
99
  def main(): # noqa: C901 - (complexity acceptable for CLI glue)
183
100
  repo_root = Path.cwd()
184
101
  print(f"Operating @ {repo_root}")
185
102
 
186
- file_path_input = input("Enter path to target file [press Enter to generate it from searching]: ").strip()
187
- if file_path_input == "":
103
+ from machineconfig.utils.options import choose_one_option
104
+
105
+ # Prompt user to choose search strategy
106
+ search_strategies = ["file_path", "keyword_search", "filename_pattern"]
107
+ search_strategy = choose_one_option(
108
+ header="Choose search strategy:",
109
+ options=search_strategies
110
+ )
111
+
112
+ # Execute chosen search strategy
113
+ if search_strategy == "file_path":
114
+ file_path_input = input("Enter path to target file: ").strip()
115
+ if not file_path_input:
116
+ print("No file path provided. Exiting.")
117
+ return
118
+ target_file_path = Path(file_path_input).expanduser().resolve()
119
+ if not target_file_path.exists() or not target_file_path.is_file():
120
+ print(f"Invalid file path: {target_file_path}")
121
+ return
122
+ separator = input("Enter separator [\\n]: ").strip() or "\n"
123
+ source_text = target_file_path.read_text(encoding="utf-8", errors="ignore")
124
+
125
+ elif search_strategy == "keyword_search":
188
126
  keyword = input("Enter keyword to search recursively for all .py files containing it: ").strip()
189
127
  if not keyword:
190
128
  print("No keyword supplied. Exiting.")
191
129
  return
192
- matching_files = _search_python_files(repo_root, keyword)
130
+ matching_files = search_python_files(repo_root, keyword)
193
131
  if not matching_files:
194
132
  print(f"💥 No .py files found containing keyword: {keyword}")
195
133
  return
@@ -200,33 +138,52 @@ def main(): # noqa: C901 - (complexity acceptable for CLI glue)
200
138
  _write_list_file(target_list_file, matching_files)
201
139
  separator = "\n"
202
140
  source_text = target_list_file.read_text(encoding="utf-8", errors="ignore")
203
- else:
204
- target_file_path = Path(file_path_input).expanduser().resolve()
205
- if not target_file_path.exists() or not target_file_path.is_file():
206
- print(f"Invalid file path: {target_file_path}")
141
+
142
+ elif search_strategy == "filename_pattern":
143
+ pattern = input("Enter filename pattern (e.g., '*.py', '*test*', 'config.*'): ").strip()
144
+ if not pattern:
145
+ print("No pattern supplied. Exiting.")
207
146
  return
208
- separator = input("Enter separator [\\n]: ").strip() or "\n"
209
- if not target_file_path.exists():
210
- print(f"File does not exist: {target_file_path}")
147
+ matching_files = search_files_by_pattern(repo_root, pattern)
148
+ if not matching_files:
149
+ print(f"💥 No files found matching pattern: {pattern}")
211
150
  return
212
- source_text = target_file_path.read_text(encoding="utf-8", errors="ignore")
213
-
151
+ for idx, mf in enumerate(matching_files):
152
+ print(f"{idx:>3}: {mf}")
153
+ print(f"\nFound {len(matching_files)} files matching pattern: {pattern}")
154
+ target_list_file = repo_root / ".ai" / "target_file.txt"
155
+ _write_list_file(target_list_file, matching_files)
156
+ separator = "\n"
157
+ source_text = target_list_file.read_text(encoding="utf-8", errors="ignore")
158
+ else:
159
+ raise ValueError(f"Unknown search strategy: {search_strategy}")
214
160
  raw_prompts = source_text.split(separator)
215
161
  print(f"Loaded {len(raw_prompts)} raw prompts from source.")
216
162
  prefix = input("Enter prefix prompt: ")
217
- combined_prompts = _chunk_prompts(raw_prompts, DEFAULT_AGENT_CAP)
163
+ # Prompt user for splitting strategy
164
+ splitting_strategy = choose_one_option(header="Select splitting strategy", options=get_args(SPLITTING_STRATEGY))
165
+ # Get parameters based on strategy
166
+ if splitting_strategy == "agent_cap":
167
+ agent_cap_input = input(f"Enter maximum number of agents/splits [default: {DEFAULT_AGENT_CAP}]: ").strip()
168
+ agent_cap = int(agent_cap_input) if agent_cap_input else DEFAULT_AGENT_CAP
169
+ combined_prompts = _chunk_prompts(raw_prompts, splitting_strategy, agent_cap=agent_cap, task_rows=None)
170
+ max_agents_for_launch = agent_cap
171
+ elif splitting_strategy == "task_rows":
172
+ task_rows_input = input("Enter number of rows/tasks per agent: ").strip()
173
+ if not task_rows_input:
174
+ print("Number of rows/tasks is required for this strategy.")
175
+ return
176
+ task_rows = int(task_rows_input)
177
+ combined_prompts = _chunk_prompts(raw_prompts, splitting_strategy, agent_cap=None, task_rows=task_rows)
178
+ max_agents_for_launch = len(combined_prompts) # Number of agents determined by chunking
179
+ else:
180
+ raise ValueError(f"Unknown splitting strategy: {splitting_strategy}")
218
181
  combined_prompts = [prefix + "\n" + p for p in combined_prompts]
219
-
220
- from machineconfig.utils.options import choose_one_option
221
-
222
182
  agent_selected = choose_one_option(header="Select agent type", options=get_args(AGENTS))
223
-
224
- tab_config = launch_agents(repo_root=repo_root, prompts=combined_prompts, agent=agent_selected, max_agents=DEFAULT_AGENT_CAP)
183
+ tab_config = launch_agents(repo_root=repo_root, prompts=combined_prompts, agent=agent_selected, max_agents=max_agents_for_launch)
225
184
  if not tab_config:
226
185
  return
227
-
228
186
  from machineconfig.utils.utils2 import randstr
229
-
230
187
  random_name = randstr(length=3)
231
188
  manager = ZellijLocalManager(session_layouts=[LayoutConfig(layoutName="Agents", layoutTabs=tab_config)], session_name_prefix=random_name)
232
189
  manager.start_all_sessions()
@@ -0,0 +1,97 @@
1
+ from machineconfig.scripts.python.fire_agents import AGENTS, get_gemini_api_keys
2
+ from machineconfig.utils.schemas.layouts.layout_types import TabConfig
3
+ from machineconfig.utils.utils2 import randstr
4
+
5
+ import random
6
+ import shlex
7
+ from pathlib import Path
8
+
9
+ def _confirm(message: str, default_no: bool = False) -> bool:
10
+ from rich.prompt import Confirm
11
+ return Confirm.ask(message, default=not default_no)
12
+
13
+
14
+ def launch_agents(repo_root: Path, prompts: list[str], agent: AGENTS, *, max_agents: int) -> list[TabConfig]:
15
+ """Create tab configuration for a set of agent prompts.
16
+
17
+ If number of prompts exceeds max_agents, ask user for confirmation.
18
+ (Original behavior raised an error; now interactive override.)
19
+ """
20
+ if not prompts:
21
+ raise ValueError("No prompts provided")
22
+
23
+ if len(prompts) > max_agents:
24
+ proceed = _confirm(message=(f"You are about to launch {len(prompts)} agents which exceeds the cap ({max_agents}). Proceed?"), default_no=True)
25
+ if not proceed:
26
+ print("Aborting per user choice.")
27
+ return []
28
+
29
+ tab_config: list[TabConfig] = []
30
+ tmp_dir = repo_root / ".ai" / f"tmp_prompts/{randstr()}"
31
+ tmp_dir.mkdir(parents=True, exist_ok=True)
32
+
33
+ for idx, a_prompt in enumerate(prompts):
34
+ prompt_path = tmp_dir / f"agent{idx}_prompt.txt"
35
+ prompt_path.write_text(a_prompt, encoding="utf-8")
36
+ cmd_path = tmp_dir / f"agent{idx}_cmd.sh"
37
+ match agent:
38
+ case "gemini":
39
+ # model = "gemini-2.5-pro"
40
+ # model = "gemini-2.5-flash-lite"
41
+ model = None # auto-select
42
+ if model is None:
43
+ model_arg = ""
44
+ else:
45
+ model_arg = f"--model {shlex.quote(model)}"
46
+ # Need a real shell for the pipeline; otherwise '| gemini ...' is passed as args to 'cat'
47
+ safe_path = shlex.quote(str(prompt_path))
48
+ api_keys = get_gemini_api_keys()
49
+ api_key = api_keys[idx % len(api_keys)] if api_keys else ""
50
+ # Export the environment variable so it's available to subshells
51
+ cmd = f"""
52
+ export GEMINI_API_KEY={shlex.quote(api_key)}
53
+ echo "Using Gemini API key $GEMINI_API_KEY"
54
+ cat {prompt_path}
55
+ GEMINI_API_KEY={shlex.quote(api_key)} bash -lc 'cat {safe_path} | gemini {model_arg} --yolo --prompt'
56
+ """
57
+ case "cursor-agent":
58
+ # As originally implemented
59
+ cmd = f"""
60
+
61
+ cursor-agent --print --output-format text < {prompt_path}
62
+
63
+ """
64
+ case "crush":
65
+ cmd = f"""
66
+ # cat {prompt_path} | crush run
67
+ crush run {prompt_path}
68
+ """
69
+ case "q":
70
+ cmd = f"""
71
+ q chat --no-interactive --trust-all-tools {prompt_path}
72
+ """
73
+ case "onlyPrepPromptFiles":
74
+ cmd = f"""
75
+ echo "Prepared prompt file at {prompt_path}"
76
+ """
77
+ case _:
78
+ raise ValueError(f"Unsupported agent type: {agent}")
79
+ random_sleep_time = random.uniform(0, 5)
80
+ cmd_prefix = f"""
81
+ echo "Sleeping for {random_sleep_time:.2f} seconds to stagger agent startups..."
82
+ sleep {random_sleep_time:.2f}
83
+ echo "Launching `{agent}` with prompt from {prompt_path}"
84
+ echo "Launching `{agent}` with command from {cmd_path}"
85
+ echo "--------START OF AGENT OUTPUT--------"
86
+ sleep 0.1
87
+ """
88
+ cmd_postfix = """
89
+ sleep 0.1
90
+ echo "---------END OF AGENT OUTPUT---------"
91
+ """
92
+ cmd_path.write_text(cmd_prefix + cmd + cmd_postfix, encoding="utf-8")
93
+ fire_cmd = f"bash {shlex.quote(str(cmd_path))}"
94
+ tab_config.append(TabConfig(tabName=f"Agent{idx}", startDir=str(repo_root), command=fire_cmd))
95
+
96
+ print(f"Launching a template with #{len(tab_config)} agents")
97
+ return tab_config
@@ -0,0 +1,83 @@
1
+ from machineconfig.utils.source_of_truth import EXCLUDE_DIRS
2
+
3
+
4
+ import fnmatch
5
+ from pathlib import Path
6
+
7
+
8
+ def search_files_by_pattern(repo_root: Path, pattern: str) -> list[Path]:
9
+ """Return all files under repo_root whose filename matches the given pattern.
10
+
11
+ Notes:
12
+ - Uses glob-style pattern matching (e.g., "*.py", "*test*", "config.*")
13
+ - Skips any paths that reside under directories listed in EXCLUDE_DIRS at any depth.
14
+ """
15
+ matches: list[Path] = []
16
+
17
+ def _should_skip_dir(dir_path: Path) -> bool:
18
+ """Check if directory should be skipped based on EXCLUDE_DIRS."""
19
+ return any(part in EXCLUDE_DIRS for part in dir_path.parts)
20
+
21
+ def _walk_and_filter(current_path: Path) -> None:
22
+ """Recursively walk directories, filtering out excluded ones early."""
23
+ try:
24
+ if not current_path.is_dir():
25
+ return
26
+
27
+ # Skip if current directory is in exclude list
28
+ if _should_skip_dir(current_path):
29
+ return
30
+
31
+ for item in current_path.iterdir():
32
+ if item.is_dir():
33
+ _walk_and_filter(item)
34
+ elif item.is_file() and fnmatch.fnmatch(item.name, pattern):
35
+ matches.append(item)
36
+ except (OSError, PermissionError):
37
+ # Skip directories we can't read
38
+ return
39
+
40
+ _walk_and_filter(repo_root)
41
+ return matches
42
+
43
+
44
+ def search_python_files(repo_root: Path, keyword: str) -> list[Path]:
45
+ """Return all Python files under repo_root whose text contains keyword.
46
+
47
+ Notes:
48
+ - Skips any paths that reside under directories listed in EXCLUDE_DIRS at any depth.
49
+ - Errors reading individual files are ignored (decoded with 'ignore').
50
+ """
51
+ keyword_lower = keyword.lower()
52
+ matches: list[Path] = []
53
+
54
+ def _should_skip_dir(dir_path: Path) -> bool:
55
+ """Check if directory should be skipped based on EXCLUDE_DIRS."""
56
+ return any(part in EXCLUDE_DIRS for part in dir_path.parts)
57
+
58
+ def _walk_and_filter(current_path: Path) -> None:
59
+ """Recursively walk directories, filtering out excluded ones early."""
60
+ try:
61
+ if not current_path.is_dir():
62
+ return
63
+
64
+ # Skip if current directory is in exclude list
65
+ if _should_skip_dir(current_path):
66
+ return
67
+
68
+ for item in current_path.iterdir():
69
+ if item.is_dir():
70
+ _walk_and_filter(item)
71
+ elif item.is_file() and item.suffix == ".py":
72
+ try:
73
+ if keyword_lower in item.read_text(encoding="utf-8", errors="ignore").lower():
74
+ matches.append(item)
75
+ except OSError:
76
+ # Skip unreadable file
77
+ continue
78
+ except (OSError, PermissionError):
79
+ # Skip directories we can't read
80
+ return
81
+
82
+ _walk_and_filter(repo_root)
83
+ return matches
@@ -1,14 +1,12 @@
1
1
  from pathlib import Path
2
2
  from machineconfig.utils.utils2 import pprint, read_json, read_ini
3
- from pydantic import ConfigDict
4
- from pydantic.dataclasses import dataclass
5
3
  from typing import Optional
6
4
  import os
7
5
  from machineconfig.utils.source_of_truth import DEFAULTS_PATH
8
6
  from rich.console import Console
9
7
  from rich.panel import Panel
10
8
  from rich import box # Import box
11
-
9
+ from dataclasses import dataclass
12
10
 
13
11
  console = Console()
14
12
 
@@ -26,8 +24,7 @@ class ArgsDefaults:
26
24
  key = None
27
25
  pwd = None
28
26
 
29
-
30
- @dataclass(config=ConfigDict(extra="forbid", frozen=False))
27
+ @dataclass
31
28
  class Args:
32
29
  cloud: Optional[str] = None
33
30
 
@@ -8,7 +8,7 @@ in the event that username@github.com is not mentioned in the remote url.
8
8
  from machineconfig.utils.source_of_truth import CONFIG_PATH, DEFAULTS_PATH
9
9
  from machineconfig.utils.path_reduced import PathExtended as PathExtended
10
10
  from machineconfig.utils.utils2 import randstr, read_ini
11
- from machineconfig.scripts.python.devops_update_repos import update_repository
11
+ from machineconfig.scripts.python.repos_helper_update import update_repository
12
12
  from machineconfig.scripts.python.repos_helper_record import main as record_repos
13
13
 
14
14
  import argparse
@@ -9,6 +9,7 @@ from machineconfig.utils.io_save import save_json
9
9
  from typing import Optional
10
10
 
11
11
  from rich import print as pprint
12
+ from rich.progress import Progress, TaskID, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn, MofNCompleteColumn
12
13
 
13
14
 
14
15
  def build_tree_structure(repos: list[RepoRecordDict], repos_root: PathExtended) -> str:
@@ -82,7 +83,7 @@ def build_tree_structure(repos: list[RepoRecordDict], repos_root: PathExtended)
82
83
  return "\n".join(tree_lines)
83
84
 
84
85
 
85
- def record_a_repo(path: PathExtended, search_parent_directories: bool = False, preferred_remote: Optional[str] = None) -> RepoRecordDict:
86
+ def record_a_repo(path: PathExtended, search_parent_directories: bool, preferred_remote: Optional[str]) -> RepoRecordDict:
86
87
  from git.repo import Repo
87
88
 
88
89
  repo = Repo(path, search_parent_directories=search_parent_directories) # get list of remotes using git python
@@ -126,27 +127,103 @@ def record_a_repo(path: PathExtended, search_parent_directories: bool = False, p
126
127
  return res
127
128
 
128
129
 
129
- def record_repos_recursively(repos_root: str, r: bool = True) -> list[RepoRecordDict]:
130
+ def count_git_repositories(repos_root: str, r: bool) -> int:
131
+ """Count total git repositories for accurate progress tracking."""
132
+ path_obj = PathExtended(repos_root).expanduser().absolute()
133
+ if path_obj.is_file():
134
+ return 0
135
+
136
+ search_res = path_obj.search("*", files=False, folders=True)
137
+ count = 0
138
+
139
+ for a_search_res in search_res:
140
+ if a_search_res.joinpath(".git").exists():
141
+ count += 1
142
+ elif r:
143
+ count += count_git_repositories(str(a_search_res), r=r)
144
+
145
+ return count
146
+
147
+
148
+ def count_total_directories(repos_root: str, r: bool) -> int:
149
+ """Count total directories to scan for accurate progress tracking."""
150
+ path_obj = PathExtended(repos_root).expanduser().absolute()
151
+ if path_obj.is_file():
152
+ return 0
153
+
154
+ search_res = path_obj.search("*", files=False, folders=True)
155
+ count = len(search_res)
156
+
157
+ if r:
158
+ for a_search_res in search_res:
159
+ if not a_search_res.joinpath(".git").exists():
160
+ count += count_total_directories(str(a_search_res), r=r)
161
+
162
+ return count
163
+
164
+
165
+ def record_repos_recursively(repos_root: str, r: bool, progress: Progress | None, scan_task_id: TaskID | None, process_task_id: TaskID | None) -> list[RepoRecordDict]:
130
166
  path_obj = PathExtended(repos_root).expanduser().absolute()
131
167
  if path_obj.is_file():
132
168
  return []
169
+
133
170
  search_res = path_obj.search("*", files=False, folders=True)
134
171
  res: list[RepoRecordDict] = []
172
+
135
173
  for a_search_res in search_res:
174
+ if progress and scan_task_id:
175
+ progress.update(scan_task_id, description=f"Scanning: {a_search_res.name}")
176
+
136
177
  if a_search_res.joinpath(".git").exists():
137
178
  try:
138
- res.append(record_a_repo(a_search_res))
179
+ if progress and process_task_id:
180
+ progress.update(process_task_id, description=f"Recording: {a_search_res.name}")
181
+
182
+ repo_record = record_a_repo(a_search_res, search_parent_directories=False, preferred_remote=None)
183
+ res.append(repo_record)
184
+
185
+ if progress and process_task_id:
186
+ progress.update(process_task_id, advance=1, description=f"Recorded: {repo_record['name']}")
139
187
  except Exception as e:
140
188
  print(f"⚠️ Failed to record {a_search_res}: {e}")
141
189
  else:
142
190
  if r:
143
- res += record_repos_recursively(str(a_search_res), r=r)
191
+ res += record_repos_recursively(str(a_search_res), r=r, progress=progress, scan_task_id=scan_task_id, process_task_id=process_task_id)
192
+
193
+ if progress and scan_task_id:
194
+ progress.update(scan_task_id, advance=1)
195
+
144
196
  return res
145
197
 
146
198
 
147
199
  def main(repos_root: PathExtended):
148
200
  print("\n📝 Recording repositories...")
149
- repo_records = record_repos_recursively(repos_root=str(repos_root))
201
+
202
+ # Count total directories and repositories for accurate progress tracking
203
+ print("🔍 Analyzing directory structure...")
204
+ total_dirs = count_total_directories(str(repos_root), r=True)
205
+ total_repos = count_git_repositories(str(repos_root), r=True)
206
+ print(f"📊 Found {total_dirs} directories to scan and {total_repos} git repositories to record")
207
+
208
+ # Setup progress bars
209
+ with Progress(
210
+ SpinnerColumn(),
211
+ TextColumn("[progress.description]{task.description}"),
212
+ BarColumn(),
213
+ MofNCompleteColumn(),
214
+ TimeElapsedColumn(),
215
+ ) as progress:
216
+ scan_task = progress.add_task("Scanning directories...", total=total_dirs)
217
+ process_task = progress.add_task("Recording repositories...", total=total_repos)
218
+
219
+ repo_records = record_repos_recursively(
220
+ repos_root=str(repos_root),
221
+ r=True,
222
+ progress=progress,
223
+ scan_task_id=scan_task,
224
+ process_task_id=process_task
225
+ )
226
+
150
227
  res: RepoRecordFile = {"version": "0.1", "repos": repo_records}
151
228
 
152
229
  # Summary with warnings