Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4a579be
ci: add lint and test workflow
LacerdaTraderCode Oct 11, 2026
5706428
build: add ruff configuration
LacerdaTraderCode Oct 11, 2026
8d03b18
test: add pytest configuration
LacerdaTraderCode Oct 11, 2026
f45ed61
build: add development requirements
LacerdaTraderCode Oct 11, 2026
10af9a7
build: make scripts an importable package
LacerdaTraderCode Oct 11, 2026
9fedb48
refactor: translate file organizer and separate logic from CLI
LacerdaTraderCode Oct 11, 2026
b285eaa
refactor: translate bulk rename, return results and guard against col…
LacerdaTraderCode Oct 11, 2026
1fd37ab
style: shorten argument help
LacerdaTraderCode Oct 11, 2026
59f19c1
refactor: translate duplicate finder and separate scanning from deletion
LacerdaTraderCode Oct 11, 2026
84dbad4
refactor: translate email sender and report missing configuration
LacerdaTraderCode Oct 11, 2026
1fd1599
refactor: translate Excel merger and return a summary
LacerdaTraderCode Oct 11, 2026
6023d6d
refactor: translate folder backup and return a result object
LacerdaTraderCode Oct 11, 2026
89eee7a
refactor: translate log analyzer and split analysis from reporting
LacerdaTraderCode Oct 11, 2026
0742980
refactor: translate system monitor and make the loop bounded for testing
LacerdaTraderCode Oct 11, 2026
c7ebf97
test: add tests package
LacerdaTraderCode Oct 11, 2026
d161e5a
test: cover file organizer
LacerdaTraderCode Oct 11, 2026
f36c353
test: cover bulk rename
LacerdaTraderCode Oct 11, 2026
8bf0197
test: cover duplicate finder
LacerdaTraderCode Oct 11, 2026
de2b799
test: make the non-duplicate fixture the same size
LacerdaTraderCode Oct 11, 2026
efc8a3f
test: cover email sender with a fake SMTP server
LacerdaTraderCode Oct 11, 2026
55aa2ef
test: assert the success exit code properly
LacerdaTraderCode Oct 11, 2026
4a50606
test: cover Excel merger
LacerdaTraderCode Oct 11, 2026
79dca54
test: correct expected merged row count
LacerdaTraderCode Oct 11, 2026
ff0e54d
test: cover folder backup
LacerdaTraderCode Oct 11, 2026
7e6549b
test: cover log analyzer
LacerdaTraderCode Oct 11, 2026
ab598af
test: cover system monitor with mocked psutil
LacerdaTraderCode Oct 11, 2026
dbf4775
test: use a named recorder in the main test
LacerdaTraderCode Oct 11, 2026
c22f5ae
docs: add CI badge
LacerdaTraderCode Oct 11, 2026
a8752f4
docs: update structure and add development section
LacerdaTraderCode Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: pip install -r requirements-dev.txt
- name: Lint
run: ruff check . && ruff format --check .
- name: Run tests
run: pytest -v
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

**A collection of Python scripts to automate everyday tasks**

[![CI](https://github.com/LacerdaTraderCode/python-automation-scripts/actions/workflows/ci.yml/badge.svg)](https://github.com/LacerdaTraderCode/python-automation-scripts/actions/workflows/ci.yml)
[![Python](https://img.shields.io/badge/Python-3.11%2B-blue?logo=python&logoColor=white)](https://www.python.org/)
[![License](https://img.shields.io/badge/License-MIT-orange)](https://github.com/LacerdaTraderCode/python-automation-scripts/blob/main/LICENSE)
[![GitHub](https://img.shields.io/badge/GitHub-LacerdaTraderCode-181717?logo=github)](https://github.com/LacerdaTraderCode/python-automation-scripts)
Expand Down Expand Up @@ -59,12 +60,27 @@ python-automation-scripts/
│ ├── system_monitor.py
│ ├── duplicate_finder.py
│ └── log_analyzer.py
├── tests/
├── .github/workflows/ci.yml
├── requirements.txt
├── requirements-dev.txt
└── README.md
```

---

## 🧪 Development

```bash
pip install -r requirements-dev.txt
ruff check . && ruff format --check .
pytest -v
```

Tests run against temporary directories with SMTP and system metrics mocked. The same checks run on every push and pull request via GitHub Actions.

---

## 📦 Installation

```bash
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[tool.ruff]
line-length = 100
exclude = [".venv"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "RUF"]
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
python_files = test_*.py
testpaths = tests
3 changes: 3 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==9.1.1
ruff==0.16.9
Empty file added scripts/__init__.py
Empty file.
81 changes: 35 additions & 46 deletions scripts/bulk_rename.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,59 @@
"""
Bulk Rename - renomeia arquivos em lote usando regex.

Uso:
python scripts/bulk_rename.py <pasta> --pattern "regex" --replacement "novo_nome"

Exemplos:
# Troca "IMG_" por "foto_"
python scripts/bulk_rename.py ./fotos --pattern "^IMG_" --replacement "foto_"

# Captura grupos e reusa
python scripts/bulk_rename.py ./fotos --pattern "IMG_(\\d+)" --replacement "foto_{1}"
"""
import argparse
import re
import sys
from pathlib import Path


def bulk_rename(folder: str, pattern: str, replacement: str, dry_run: bool = False):
"""Renomeia arquivos que batem com o pattern regex."""
def bulk_rename(
folder: str, pattern: str, replacement: str, dry_run: bool = False
) -> list[tuple[str, str]]:
folder_path = Path(folder).expanduser().resolve()

if not folder_path.is_dir():
print(f"❌ Pasta inválida: {folder_path}")
return
raise NotADirectoryError(f"Invalid folder: {folder_path}")

regex = re.compile(pattern)
count = 0

# Converte {1}, {2} etc. para \1, \2 do regex
repl = re.sub(r"\{(\d+)\}", r"\\\1", replacement)
group_replacement = re.sub(r"\{(\d+)\}", r"\\\1", replacement)

print(f"🔍 Procurando em: {folder_path}")
print(f"📝 Pattern: {pattern}")
print(f"📝 Replacement: {replacement}\n")

for item in folder_path.iterdir():
renames = []
for item in sorted(folder_path.iterdir()):
if not item.is_file():
continue

new_name = regex.sub(repl, item.name)
new_name = regex.sub(group_replacement, item.name)
if new_name == item.name:
continue

new_path = item.parent / new_name
target = item.parent / new_name
if target.exists():
raise FileExistsError(f"Target already exists: {target}")

if not dry_run:
item.rename(target)
renames.append((item.name, new_name))

if dry_run:
print(f" [DRY] {item.name} → {new_name}")
else:
item.rename(new_path)
print(f" ✓ {item.name} → {new_name}")
count += 1
return renames

action = "Simulados" if dry_run else "Renomeados"
print(f"\n✅ {action}: {count} arquivo(s)")

def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Bulk rename files using a regex")
parser.add_argument("folder", help="Folder containing the files")
parser.add_argument("--pattern", required=True, help="Regex to search for")
parser.add_argument("--replacement", required=True, help="Replacement ({1} for groups)")
parser.add_argument("--dry-run", action="store_true", help="Preview without renaming")
args = parser.parse_args(argv)

def main():
parser = argparse.ArgumentParser(description="Renomeação em lote com regex")
parser.add_argument("folder", help="Pasta com os arquivos")
parser.add_argument("--pattern", required=True, help="Regex para procurar")
parser.add_argument("--replacement", required=True, help="Texto substituto")
parser.add_argument("--dry-run", action="store_true", help="Simula sem renomear")
try:
renames = bulk_rename(args.folder, args.pattern, args.replacement, args.dry_run)
except (NotADirectoryError, FileExistsError, re.error) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1

args = parser.parse_args()
bulk_rename(args.folder, args.pattern, args.replacement, args.dry_run)
for old_name, new_name in renames:
print(f"{old_name} -> {new_name}")
label = "Would rename" if args.dry_run else "Renamed"
print(f"{label}: {len(renames)} file(s)")
return 0


if __name__ == "__main__":
main()
raise SystemExit(main())
141 changes: 64 additions & 77 deletions scripts/duplicate_finder.py
Original file line number Diff line number Diff line change
@@ -1,109 +1,96 @@
"""
Duplicate Finder - encontra arquivos duplicados por hash MD5.

Uso: python scripts/duplicate_finder.py <pasta> [--delete]
"""
import sys
import hashlib
import argparse
from pathlib import Path
import hashlib
import sys
from collections import defaultdict
from pathlib import Path


def file_hash(filepath: Path, chunk_size: int = 8192) -> str:
"""Calcula MD5 de um arquivo em chunks (eficiente para arquivos grandes)."""
md5 = hashlib.md5()
with filepath.open("rb") as f:
while chunk := f.read(chunk_size):
md5.update(chunk)
return md5.hexdigest()


def find_duplicates(folder: str, delete: bool = False) -> dict:
"""
Encontra arquivos duplicados comparando hashes.
Estratégia otimizada: primeiro agrupa por tamanho, depois por hash.
"""
digest = hashlib.md5(usedforsecurity=False)
with filepath.open("rb") as handle:
while chunk := handle.read(chunk_size):
digest.update(chunk)
return digest.hexdigest()


def find_duplicates(folder: str) -> list[list[Path]]:
folder_path = Path(folder).expanduser().resolve()
if not folder_path.is_dir():
print(f"❌ Pasta inválida: {folder_path}")
return {}

print(f"🔍 Buscando duplicatas em: {folder_path}\n")
raise NotADirectoryError(f"Invalid folder: {folder_path}")

# Passo 1: agrupar por tamanho (rápido)
by_size = defaultdict(list)
total_files = 0
for file in folder_path.rglob("*"):
if file.is_file():
try:
try:
if file.is_file():
by_size[file.stat().st_size].append(file)
total_files += 1
except (OSError, PermissionError):
continue
except OSError:
continue

# Passo 2: hash apenas onde há mesmo tamanho
by_hash = defaultdict(list)
for size, files in by_size.items():
if len(files) < 2 or size == 0:
if size == 0 or len(files) < 2:
continue
for file in files:
try:
h = file_hash(file)
by_hash[h].append(file)
except (OSError, PermissionError):
by_hash[file_hash(file)].append(file)
except OSError:
continue

# Filtrar só duplicatas
duplicates = {h: files for h, files in by_hash.items() if len(files) > 1}
groups = [sorted(files) for files in by_hash.values() if len(files) > 1]
return sorted(groups)


def wasted_bytes(groups: list[list[Path]]) -> int:
return sum(group[0].stat().st_size * (len(group) - 1) for group in groups)

if not duplicates:
print(f"✅ Nenhum duplicado encontrado em {total_files:,} arquivos.")
return {}

total_duplicates = sum(len(files) - 1 for files in duplicates.values())
total_waste = sum(
files[0].stat().st_size * (len(files) - 1)
for files in duplicates.values()
)
def delete_duplicates(groups: list[list[Path]]) -> list[Path]:
removed = []
for group in groups:
for file in group[1:]:
try:
file.unlink()
except OSError:
continue
removed.append(file)
return removed

print(f"📊 Resultado:")
print(f" Arquivos analisados: {total_files:,}")
print(f" Grupos de duplicatas: {len(duplicates)}")
print(f" Arquivos redundantes: {total_duplicates:,}")
print(f" Espaço desperdiçado: {total_waste / 1024 / 1024:.2f} MB\n")

for idx, (h, files) in enumerate(duplicates.items(), 1):
print(f"Grupo {idx} ({len(files)} cópias, hash {h[:8]}...):")
for i, file in enumerate(files):
marker = " [ORIGINAL]" if i == 0 else " [DUPLICATA]"
print(f" {marker} {file}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Find duplicate files by content hash")
parser.add_argument("folder", help="Folder to analyze")
parser.add_argument("--delete", action="store_true", help="Delete extra copies")
args = parser.parse_args(argv)

if delete and i > 0:
try:
file.unlink()
print(f" 🗑️ Removido: {file.name}")
except Exception as e:
print(f" ❌ Erro ao remover: {e}")
print()
if args.delete:
answer = input("Delete duplicate files? [y/N]: ")
if answer.strip().lower() != "y":
print("Cancelled.")
return 0

return duplicates
try:
groups = find_duplicates(args.folder)
except NotADirectoryError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1

if not groups:
print("No duplicates found.")
return 0

def main():
parser = argparse.ArgumentParser(description="Encontra arquivos duplicados")
parser.add_argument("folder", help="Pasta para analisar")
parser.add_argument("--delete", action="store_true", help="DELETA duplicatas (mantém a primeira)")
args = parser.parse_args()
for index, group in enumerate(groups, 1):
print(f"Group {index} ({len(group)} copies):")
for position, file in enumerate(group):
label = "original" if position == 0 else "duplicate"
print(f" [{label}] {file}")

if args.delete:
resp = input("⚠️ Tem certeza que quer DELETAR as duplicatas? [s/N]: ")
if resp.lower() != "s":
print("Cancelado.")
sys.exit(0)
print(f"\nWasted space: {wasted_bytes(groups) / 1024 / 1024:.2f} MB")

find_duplicates(args.folder, args.delete)
if args.delete:
removed = delete_duplicates(groups)
print(f"Removed {len(removed)} file(s)")
return 0


if __name__ == "__main__":
main()
raise SystemExit(main())
Loading
Loading