From b4c8f8bf8d6a5694d5a759535f3899b87182bb86 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 06:52:30 +0000 Subject: [PATCH 1/3] Refactor: Migrate from print statements to Python's logging module This commit replaces all instances of `print()` with calls to Python's standard `logging` module. Key changes: - Configured a logger in `sbg.py` with a standard format and INFO level. - Replaced informational, warning, and error `print()` calls with `logger.info()`, `logger.warning()`, and `logger.error()` respectively. - Handled `sys.exit()` calls to log critical errors or informational messages (like user abortion) before exiting. - Updated unit tests in `test_sbg.py` to use `self.assertLogs` for capturing and verifying log output, ensuring continued test coverage. - Added a note to `README.md` about the use of the logging module. This change provides more structured and configurable logging, allowing you to control log verbosity and output destinations (e.g., file output) if needed in the future. --- README.md | 6 ++++++ sbg.py | 24 +++++++++++++++--------- test_sbg.py | 28 ++++++++++------------------ 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index f3cf778..49cbbf4 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,12 @@ python sbg.py \ --- +## Logging + +The script uses Python's standard `logging` module for all console output. This provides users with more control over verbosity and output destinations (e.g., logging to a file). By default, it logs messages at the `INFO` level and above to standard output. + +--- + ## Script Reference ```text diff --git a/sbg.py b/sbg.py index f24788e..9bd14f4 100755 --- a/sbg.py +++ b/sbg.py @@ -3,7 +3,10 @@ import sys import argparse import requests +import logging import subprocess + +logger = logging.getLogger(__name__) from urllib.parse import urljoin def parse_args(): @@ -68,7 +71,7 @@ def gather_all_projects(self, group_id): projs = self.list_projects(gid) subs = self.list_subgroups(gid) except requests.HTTPError as error: - print(f"Warning: could not fetch for group {gid}: {error}") + logger.warning(f"Could not fetch for group {gid}: {error}") continue projects.extend(projs) for sg in subs: @@ -79,29 +82,30 @@ def gather_all_projects(self, group_id): def clone_or_pull(repo_url, target_path): """Clone if missing, or pull if already a Git repo.""" if os.path.isdir(target_path) and os.path.isdir(os.path.join(target_path, ".git")): - print(f"Updating existing repo at {target_path}") + logger.info(f"Updating existing repo at {target_path}") try: subprocess.check_call(["git", "-C", target_path, "pull"]) except subprocess.CalledProcessError as error: - print(f" ❌ pull failed for {target_path}: {error}") + logger.error(f"Pull failed for {target_path}: {error}") else: - print(f"Cloning into {target_path}") + logger.info(f"Cloning into {target_path}") try: subprocess.check_call(["git", "clone", repo_url, target_path]) except subprocess.CalledProcessError as error: - print(f" ❌ clone failed for {repo_url} into {target_path}: {error}") + logger.error(f"Clone failed for {repo_url} into {target_path}: {error}") def main(): + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', force=True) args = parse_args() cloner = GitLabCloner(args.gitlab_url, args.token, args.use_ssh) # 1) Gather & dedupe all projects all_projects = {} for gid in args.group_ids: - print(f"Fetching projects under group '{gid}' …") + logger.info(f"Fetching projects under group '{gid}' …") for proj in cloner.gather_all_projects(gid): all_projects[proj["id"]] = proj - print(f"Total unique projects to process: {len(all_projects)}") + logger.info(f"Total unique projects to process: {len(all_projects)}") # 2) Ensure destination root exists dest_root = os.path.abspath(args.dest) @@ -126,6 +130,8 @@ def main(): try: main() except KeyboardInterrupt: - sys.exit("\nAborted by user") + logger.info("Aborted by user") + sys.exit(0) except Exception as e: - sys.exit(f"Fatal error: {e}") + logger.critical(f"Fatal error: {e}") + sys.exit(1) diff --git a/test_sbg.py b/test_sbg.py index fb2e3ca..663e7e5 100644 --- a/test_sbg.py +++ b/test_sbg.py @@ -6,6 +6,7 @@ import argparse import requests from io import StringIO +import logging # Import the module to test import sbg @@ -39,6 +40,8 @@ def setUp(self): self.cloner = sbg.GitLabCloner('https://gitlab.com', 'token123', False) self.session_mock = MagicMock() self.cloner.session = self.session_mock + # Configure logging for sbg.py to be captured by assertLogs + logging.getLogger(sbg.__name__).setLevel(logging.INFO) # Or DEBUG for more verbosity def test_init(self): cloner = sbg.GitLabCloner('https://gitlab.com/', 'token123', True) @@ -131,18 +134,12 @@ def side_effect(path, params=None): self.cloner._get = MagicMock(side_effect=side_effect) - # Redirect stdout to capture warnings - saved_stdout = sys.stdout - try: - out = StringIO() - sys.stdout = out + with self.assertLogs(logging.getLogger(sbg.__name__), level='WARNING') as cm: result = self.cloner.gather_all_projects(123) - # Should have 2 projects and a warning - self.assertEqual(len(result), 2) - self.assertIn("Warning", out.getvalue()) - finally: - sys.stdout = saved_stdout + # Should have 2 projects and a warning + self.assertEqual(len(result), 2) + self.assertTrue(any("Could not fetch for group 456" in message for message in cm.output)) @patch('subprocess.check_call') @patch('os.path.isdir') @@ -172,15 +169,10 @@ def test_clone_error_handling(self, isdir_mock, subprocess_mock): isdir_mock.return_value = False subprocess_mock.side_effect = subprocess.CalledProcessError(1, 'git clone') - # Redirect stdout to capture error message - saved_stdout = sys.stdout - try: - out = StringIO() - sys.stdout = out + with self.assertLogs(logging.getLogger(sbg.__name__), level='ERROR') as cm: sbg.GitLabCloner.clone_or_pull('https://gitlab.com/user/repo.git', '/tmp/repo') - self.assertIn("❌ clone failed", out.getvalue()) - finally: - sys.stdout = saved_stdout + + self.assertTrue(any("Clone failed for https://gitlab.com/user/repo.git into /tmp/repo" in message for message in cm.output)) @patch('sbg.GitLabCloner') From 8a768faf7f59e262a27fc243c9b3dc36d76162a1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 06:57:44 +0000 Subject: [PATCH 2/3] Refactor: Use %-formatting for log messages This commit updates logger calls in `sbg.py` to use %-formatting with positional arguments (e.g., `logger.info("Message: %s", arg)`) instead of f-strings. This change adheres to logging best practices, as it avoids unnecessary string formatting if the log message's level is not high enough to be emitted. Unit tests in `test_sbg.py` have been confirmed to pass with these changes, as the final log output remains the same. --- sbg.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sbg.py b/sbg.py index 9bd14f4..4ab9cdd 100755 --- a/sbg.py +++ b/sbg.py @@ -71,7 +71,7 @@ def gather_all_projects(self, group_id): projs = self.list_projects(gid) subs = self.list_subgroups(gid) except requests.HTTPError as error: - logger.warning(f"Could not fetch for group {gid}: {error}") + logger.warning("Could not fetch for group %s: %s", gid, error) continue projects.extend(projs) for sg in subs: @@ -82,17 +82,17 @@ def gather_all_projects(self, group_id): def clone_or_pull(repo_url, target_path): """Clone if missing, or pull if already a Git repo.""" if os.path.isdir(target_path) and os.path.isdir(os.path.join(target_path, ".git")): - logger.info(f"Updating existing repo at {target_path}") + logger.info("Updating existing repo at %s", target_path) try: subprocess.check_call(["git", "-C", target_path, "pull"]) except subprocess.CalledProcessError as error: - logger.error(f"Pull failed for {target_path}: {error}") + logger.error("Pull failed for %s: %s", target_path, error) else: - logger.info(f"Cloning into {target_path}") + logger.info("Cloning into %s", target_path) try: subprocess.check_call(["git", "clone", repo_url, target_path]) except subprocess.CalledProcessError as error: - logger.error(f"Clone failed for {repo_url} into {target_path}: {error}") + logger.error("Clone failed for %s into %s: %s", repo_url, target_path, error) def main(): logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', force=True) @@ -102,10 +102,10 @@ def main(): # 1) Gather & dedupe all projects all_projects = {} for gid in args.group_ids: - logger.info(f"Fetching projects under group '{gid}' …") + logger.info("Fetching projects under group '%s' …", gid) for proj in cloner.gather_all_projects(gid): all_projects[proj["id"]] = proj - logger.info(f"Total unique projects to process: {len(all_projects)}") + logger.info("Total unique projects to process: %s", len(all_projects)) # 2) Ensure destination root exists dest_root = os.path.abspath(args.dest) @@ -133,5 +133,5 @@ def main(): logger.info("Aborted by user") sys.exit(0) except Exception as e: - logger.critical(f"Fatal error: {e}") + logger.critical("Fatal error: %s", e) sys.exit(1) From 90c67d00c8dc54e30e029c8cc62e0214bf711c53 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 06:58:28 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`mig?= =?UTF-8?q?rate-to-logging`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @tutunak. * https://github.com/tutunak/sb-gitlab/pull/4#issuecomment-2918490929 The following files were modified: * `sbg.py` --- sbg.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) mode change 100755 => 100644 sbg.py diff --git a/sbg.py b/sbg.py old mode 100755 new mode 100644 index 4ab9cdd..0ae9546 --- a/sbg.py +++ b/sbg.py @@ -10,6 +10,12 @@ from urllib.parse import urljoin def parse_args(): + """ + Parses command-line arguments for cloning or updating GitLab repositories. + + Returns: + argparse.Namespace: Parsed arguments including GitLab URL, access token, group IDs or paths, destination directory, and SSH usage flag. + """ p = argparse.ArgumentParser( description="Clone or pull all GitLab repos under one or more groups " "(including nested subgroups) into folders by namespace." @@ -59,7 +65,15 @@ def list_projects(self, group_id): params={"include_subgroups": False}) def gather_all_projects(self, group_id): - """Recursively gather all projects under group_id.""" + """ + Recursively collects all projects within a group and its nested subgroups. + + Args: + group_id: The ID of the root group to search. + + Returns: + A list of project metadata dictionaries for all projects found under the group and its subgroups. + """ projects = [] stack, seen = [group_id], set() while stack: @@ -80,7 +94,11 @@ def gather_all_projects(self, group_id): @staticmethod def clone_or_pull(repo_url, target_path): - """Clone if missing, or pull if already a Git repo.""" + """ + Clones a Git repository to the target path or updates it if already present. + + If the target path is an existing Git repository, performs a `git pull` to update it. Otherwise, clones the repository from the given URL into the target path. Logs errors if cloning or pulling fails. + """ if os.path.isdir(target_path) and os.path.isdir(os.path.join(target_path, ".git")): logger.info("Updating existing repo at %s", target_path) try: @@ -95,6 +113,11 @@ def clone_or_pull(repo_url, target_path): logger.error("Clone failed for %s into %s: %s", repo_url, target_path, error) def main(): + """ + Clones or updates all GitLab repositories under specified groups into a local directory. + + Parses command-line arguments, gathers all projects (including those in nested subgroups) for each provided group, deduplicates them, ensures the destination directory structure exists, and clones or pulls each repository into its corresponding namespace folder using either SSH or HTTP URLs. + """ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', force=True) args = parse_args() cloner = GitLabCloner(args.gitlab_url, args.token, args.use_ssh)