commit 1e6b55d20b6541c9dd37210d6847aeb44e418e50 Author: mahdi tavakoli Date: Wed Apr 8 12:33:06 2026 +0330 Initial gradle-mirror CLI project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4cdd490 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ diff --git a/README.fa.md b/README.fa.md new file mode 100644 index 0000000..580135e --- /dev/null +++ b/README.fa.md @@ -0,0 +1,117 @@ +# gradle-mirror + +`gradle-mirror` یک ابزار خط فرمان (CLI) با پایتون است که با ویرایش فایل سراسری Gradle (`init.gradle.kts`) آینه‌های مخزن (mirror repositories) را فعال یا غیرفعال می‌کند. + +## قابلیت‌ها + +- فعال‌سازی آینه‌ها با درج یک بلاک مدیریت‌شده داخل `init.gradle.kts` +- غیرفعال‌سازی با بازیابی فایل پشتیبان یا حذف بلاک بین markerها +- رفتار idempotent (اجرای چندباره `active` باعث تکرار بلاک نمی‌شود) +- خروجی رنگی در CLI +- لاگ‌گیری از عملیات +- سازگار با Linux، macOS و Windows + +## ساختار پروژه + +```text +gradle_mirror/ + __init__.py + init.py + cli.py + mirror_manager.py + gradle_locator.py + constants.py + utils.py + main.py +pyproject.toml +README.md +README.fa.md +``` + +## پیش‌نیازها + +- Python 3.10 یا بالاتر +- pip + +## نصب + +### روش 1 (پیشنهادی): نصب به‌صورت ابزار CLI + +در ریشه پروژه اجرا کنید: + +```bash +python -m pip install . +``` + +بعد از نصب: + +```bash +gradle-mirror --help +``` + +### روش 2: اجرا بدون نصب + +```bash +python -m gradle_mirror.main --help +``` + +## استفاده + +### فعال‌سازی آینه‌ها + +```bash +gradle-mirror active +``` + +نمونه خروجی: + +```text +✅ Gradle mirror activated +📁 Updated /Users/you/.gradle/init.gradle.kts +``` + +### غیرفعال‌سازی آینه‌ها + +```bash +gradle-mirror deactive +``` + +## نحوه کار ابزار + +- مسیر Gradle User Home به این ترتیب تشخیص داده می‌شود: + 1. اگر متغیر محیطی `GRADLE_USER_HOME` تنظیم شده باشد، همان استفاده می‌شود. + 2. در غیر این صورت، مسیر پیش‌فرض `~/.gradle` استفاده می‌شود. +- فایل هدف: `/init.gradle.kts` +- markerهای بلاک مدیریت‌شده: + - `/* gradle-mirror-start */` + - `/* gradle-mirror-end */` +- فایل پشتیبان: `init.gradle.kts.bak` +- فایل لاگ: `gradle-mirror.log` + +## نکات مهم + +- فرمان `active` اگر markerها از قبل موجود باشند، محتوای بین آن‌ها را جایگزین می‌کند (بدون تکرار). +- فرمان `deactive` ابتدا تلاش می‌کند از فایل پشتیبان بازیابی کند. +- اگر بعد از حذف بلاک، فایل `init.gradle.kts` خالی شود، فایل حذف می‌شود. + +## عیب‌یابی سریع + +- اگر خطای `No module named typer` گرفتید: + +```bash +python -m pip install . +``` + +یا: + +```bash +python -m pip install typer +``` + +- برای بررسی لاگ‌ها، فایل زیر را ببینید: + +```text +~/.gradle/gradle-mirror.log +``` + +(اگر `GRADLE_USER_HOME` تنظیم شده باشد، لاگ داخل همان مسیر ساخته می‌شود.) diff --git a/README.md b/README.md new file mode 100644 index 0000000..14a23d3 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# gradle-mirror + +`gradle-mirror` is a production-quality Python CLI for switching Gradle repository mirrors on and off globally for Android/Gradle projects. + +When enabled, it manages a safe, idempotent block inside your global Gradle init script (`init.gradle.kts`) and enforces mirror repositories for both project dependencies and plugin management. + +## Why this tool + +- One command to activate mirrors globally +- One command to restore original behavior +- Backup/restore support for safe rollback +- Idempotent updates (no duplicated blocks) +- Colored console output and action logs +- Cross-platform support (Linux, macOS, Windows) + +## Project structure + +```text +gradle_mirror/ + __init__.py + init.py + cli.py + mirror_manager.py + gradle_locator.py + constants.py + utils.py + main.py +pyproject.toml +README.md +README.fa.md +``` + +## Requirements + +- Python 3.10+ +- pip + +## Installation + +### Install as CLI (recommended) + +```bash +python -m pip install . +``` + +Then verify: + +```bash +gradle-mirror --help +``` + +### Run without install + +```bash +python -m gradle_mirror.main --help +``` + +## Usage + +Activate mirrors: + +```bash +gradle-mirror active +``` + +Deactivate mirrors: + +```bash +gradle-mirror deactive +``` + +## What `active` does + +- Resolves Gradle home from `GRADLE_USER_HOME` or falls back to `~/.gradle` +- Targets `/init.gradle.kts` +- Creates backup `init.gradle.kts.bak` if file already exists +- Inserts/replaces content between: + - `/* gradle-mirror-start */` + - `/* gradle-mirror-end */` +- Enforces mirror repositories for: + - `allprojects.repositories` + - `settings.pluginManagement.repositories` + +## What `deactive` does + +- Restores from `init.gradle.kts.bak` when available +- Otherwise removes managed marker block from `init.gradle.kts` +- Deletes `init.gradle.kts` if it becomes empty + +## Logging + +- Log file: `/gradle-mirror.log` + +## Example output + +```text +✅ Gradle mirror activated +📁 Updated /Users/you/.gradle/init.gradle.kts +``` diff --git a/gradle_mirror/__init__.py b/gradle_mirror/__init__.py new file mode 100644 index 0000000..0423aed --- /dev/null +++ b/gradle_mirror/__init__.py @@ -0,0 +1,4 @@ +"""gradle_mirror package.""" + +__all__ = ["__version__"] +__version__ = "1.0.0" diff --git a/gradle_mirror/cli.py b/gradle_mirror/cli.py new file mode 100644 index 0000000..8aca3e7 --- /dev/null +++ b/gradle_mirror/cli.py @@ -0,0 +1,52 @@ +"""Typer CLI entrypoint for gradle-mirror.""" + +from __future__ import annotations + +import typer + +from .gradle_locator import detect_gradle_user_home +from .mirror_manager import MirrorManager +from .utils import setup_logger + +app = typer.Typer( + add_completion=False, + no_args_is_help=True, + help="Activate/deactivate Gradle mirror repositories via init.gradle.kts.", +) + + +def _build_manager() -> MirrorManager: + gradle_home = detect_gradle_user_home() + logger = setup_logger(gradle_home) + return MirrorManager(gradle_home=gradle_home, logger=logger) + + +@app.command() +def active() -> None: + """Activate global mirror configuration.""" + + try: + manager = _build_manager() + result = manager.activate() + typer.secho(f"✅ {result.message}", fg=typer.colors.GREEN) + typer.secho(f"📁 Updated {result.init_file}", fg=typer.colors.BRIGHT_BLUE) + except Exception as exc: # pragma: no cover - top-level safety + typer.secho(f"❌ Failed to activate mirror: {exc}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from exc + + +@app.command() +def deactive() -> None: + """Deactivate mirror configuration.""" + + try: + manager = _build_manager() + result = manager.deactivate() + color = typer.colors.YELLOW if not result.changed else typer.colors.GREEN + emoji = "ℹ️" if not result.changed else "✅" + typer.secho(f"{emoji} {result.message}", fg=color) + if result.init_file.exists(): + typer.secho(f"📁 Current file {result.init_file}", fg=typer.colors.BRIGHT_BLUE) + except Exception as exc: # pragma: no cover - top-level safety + typer.secho(f"❌ Failed to deactivate mirror: {exc}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from exc diff --git a/gradle_mirror/constants.py b/gradle_mirror/constants.py new file mode 100644 index 0000000..0da0bd7 --- /dev/null +++ b/gradle_mirror/constants.py @@ -0,0 +1,22 @@ +"""Application constants used by gradle_mirror.""" + +from __future__ import annotations + +from pathlib import Path + +APP_NAME = "gradle-mirror" +INIT_FILE_NAME = "init.gradle.kts" +BACKUP_SUFFIX = ".bak" +LOG_FILE_NAME = "gradle-mirror.log" + +START_MARKER = "/* gradle-mirror-start */" +END_MARKER = "/* gradle-mirror-end */" + +MIRROR_REPOSITORIES = [ + "https://maven.myket.ir/", + "https://mirror-maven.runflare.com/maven2/", + "https://mirror-maven.runflare.com/android/maven2/", + "https://mirror-maven.runflare.com/gradle-plugins/", +] + +DEFAULT_GRADLE_HOME = Path.home() / ".gradle" diff --git a/gradle_mirror/gradle_locator.py b/gradle_mirror/gradle_locator.py new file mode 100644 index 0000000..4e41c6a --- /dev/null +++ b/gradle_mirror/gradle_locator.py @@ -0,0 +1,21 @@ +"""Gradle home detection utilities.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from .constants import DEFAULT_GRADLE_HOME + + +def detect_gradle_user_home() -> Path: + """Return Gradle user home using env override when set. + + Gradle honors ``GRADLE_USER_HOME``. If it is absent, default to ``~/.gradle``. + """ + + env_home = os.getenv("GRADLE_USER_HOME") + if env_home: + return Path(env_home).expanduser().resolve() + + return DEFAULT_GRADLE_HOME diff --git a/gradle_mirror/init.py b/gradle_mirror/init.py new file mode 100644 index 0000000..7ba5cbf --- /dev/null +++ b/gradle_mirror/init.py @@ -0,0 +1,8 @@ +"""Compatibility module requested by project structure. + +Use ``gradle_mirror.__init__`` for package metadata. +""" + +from .__init__ import __version__ + +__all__ = ["__version__"] diff --git a/gradle_mirror/main.py b/gradle_mirror/main.py new file mode 100644 index 0000000..bf3b642 --- /dev/null +++ b/gradle_mirror/main.py @@ -0,0 +1,9 @@ +"""Executable module for `python -m gradle_mirror.main`.""" + +from __future__ import annotations + +from .cli import app + + +if __name__ == "__main__": + app() diff --git a/gradle_mirror/mirror_manager.py b/gradle_mirror/mirror_manager.py new file mode 100644 index 0000000..e47133f --- /dev/null +++ b/gradle_mirror/mirror_manager.py @@ -0,0 +1,176 @@ +"""Core business logic for activating/deactivating Gradle mirrors.""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +from .constants import ( + BACKUP_SUFFIX, + END_MARKER, + INIT_FILE_NAME, + START_MARKER, +) +from .utils import read_text_if_exists, write_text + + +@dataclass(frozen=True) +class OperationResult: + """Result object returned by mirror operations.""" + + changed: bool + message: str + init_file: Path + backup_file: Path + + +class MirrorManager: + """Manage insertion/removal of mirror repositories in init.gradle.kts.""" + + def __init__(self, gradle_home: Path, logger: logging.Logger) -> None: + self.gradle_home = gradle_home + self.logger = logger + self.init_file = self.gradle_home / INIT_FILE_NAME + self.backup_file = self.gradle_home / f"{INIT_FILE_NAME}{BACKUP_SUFFIX}" + + def activate(self) -> OperationResult: + """Activate global repository mirrors via init.gradle.kts.""" + + self.gradle_home.mkdir(parents=True, exist_ok=True) + existing = read_text_if_exists(self.init_file) + + if self.init_file.exists(): + # Keep the original state so deactivation can restore it exactly. + write_text(self.backup_file, existing) + self.logger.info("Created backup at %s", self.backup_file) + + block = self._build_marked_block() + updated = self._upsert_marked_block(existing, block) + + if updated == existing: + self.logger.info("Mirror block already up-to-date: %s", self.init_file) + return OperationResult( + changed=False, + message="Gradle mirror is already active", + init_file=self.init_file, + backup_file=self.backup_file, + ) + + write_text(self.init_file, updated) + self.logger.info("Activated mirrors in %s", self.init_file) + return OperationResult( + changed=True, + message="Gradle mirror activated", + init_file=self.init_file, + backup_file=self.backup_file, + ) + + def deactivate(self) -> OperationResult: + """Deactivate mirror configuration and restore original behavior.""" + + if self.backup_file.exists(): + backup_content = read_text_if_exists(self.backup_file) + if backup_content.strip(): + write_text(self.init_file, backup_content) + self.logger.info("Restored init script from backup %s", self.backup_file) + else: + if self.init_file.exists(): + self.init_file.unlink() + self.logger.info("Deleted empty init script %s", self.init_file) + self.backup_file.unlink(missing_ok=True) + self.logger.info("Removed backup file %s", self.backup_file) + return OperationResult( + changed=True, + message="Gradle mirror deactivated (restored from backup)", + init_file=self.init_file, + backup_file=self.backup_file, + ) + + if not self.init_file.exists(): + self.logger.info("Nothing to deactivate; %s does not exist", self.init_file) + return OperationResult( + changed=False, + message="Nothing to deactivate", + init_file=self.init_file, + backup_file=self.backup_file, + ) + + current = read_text_if_exists(self.init_file) + updated = self._remove_marked_block(current) + + if updated == current: + self.logger.info("No mirror markers found in %s", self.init_file) + return OperationResult( + changed=False, + message="Mirror markers were not found", + init_file=self.init_file, + backup_file=self.backup_file, + ) + + if updated.strip(): + write_text(self.init_file, updated) + self.logger.info("Removed mirror block from %s", self.init_file) + else: + self.init_file.unlink(missing_ok=True) + self.logger.info("Removed empty init script %s", self.init_file) + + return OperationResult( + changed=True, + message="Gradle mirror deactivated", + init_file=self.init_file, + backup_file=self.backup_file, + ) + + def _upsert_marked_block(self, content: str, marked_block: str) -> str: + pattern = self._marker_pattern() + + if pattern.search(content): + return pattern.sub(marked_block, content) + + base = content.rstrip() + if base: + return f"{base}\n\n{marked_block}\n" + return f"{marked_block}\n" + + def _remove_marked_block(self, content: str) -> str: + pattern = self._marker_pattern() + removed = pattern.sub("", content) + + # Normalize repeated blank lines left by block removal. + removed = re.sub(r"\n{3,}", "\n\n", removed) + return removed.strip() + ("\n" if removed.strip() else "") + + def _marker_pattern(self) -> re.Pattern[str]: + escaped_start = re.escape(START_MARKER) + escaped_end = re.escape(END_MARKER) + return re.compile(rf"{escaped_start}[\s\S]*?{escaped_end}", re.MULTILINE) + + def _build_marked_block(self) -> str: + script_body = """allprojects { + repositories { + clear() + + maven { url = uri(\"https://maven.myket.ir/\") } + maven { url = uri(\"https://mirror-maven.runflare.com/maven2/\") } + maven { url = uri(\"https://mirror-maven.runflare.com/android/maven2/\") } + maven { url = uri(\"https://mirror-maven.runflare.com/gradle-plugins/\") } + + mavenCentral() + google() + } +} + +gradle.settingsEvaluated { + settings.pluginManagement.repositories.apply { + clear() + + maven { url = uri(\"https://maven.myket.ir/\") } + maven { url = uri(\"https://mirror-maven.runflare.com/gradle-plugins/\") } + + mavenCentral() + google() + } +}""" + return f"{START_MARKER}\n{script_body}\n{END_MARKER}" diff --git a/gradle_mirror/utils.py b/gradle_mirror/utils.py new file mode 100644 index 0000000..76cbf83 --- /dev/null +++ b/gradle_mirror/utils.py @@ -0,0 +1,50 @@ +"""General utility helpers for file operations and logging.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from .constants import APP_NAME, LOG_FILE_NAME + + +def setup_logger(gradle_home: Path) -> logging.Logger: + """Configure and return the application logger. + + Logs are written to ``/gradle-mirror.log`` and include timestamps. + """ + + gradle_home.mkdir(parents=True, exist_ok=True) + log_path = gradle_home / LOG_FILE_NAME + + logger = logging.getLogger(APP_NAME) + if logger.handlers: + return logger + + logger.setLevel(logging.INFO) + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + file_handler = logging.FileHandler(log_path, encoding="utf-8") + file_handler.setFormatter(formatter) + + logger.addHandler(file_handler) + logger.propagate = False + return logger + + +def read_text_if_exists(path: Path) -> str: + """Read UTF-8 text if file exists; otherwise return empty string.""" + + if not path.exists(): + return "" + return path.read_text(encoding="utf-8") + + +def write_text(path: Path, content: str) -> None: + """Write UTF-8 text content, creating parent folders if needed.""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6e45d04 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gradle-mirror" +version = "1.0.0" +description = "CLI to activate/deactivate Gradle repository mirrors via init.gradle.kts" +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "Your Team" } +] +dependencies = [ + "typer>=0.12,<1.0" +] + +[project.scripts] +gradle-mirror = "gradle_mirror.cli:app" + +[tool.setuptools] +packages = ["gradle_mirror"]