Automação com Python: arquivos, pastas e sistema operacional

Automação com Python: arquivos, pastas e sistema operacional

Python para automatizar arquivos e o sistema: pathlib, shutil, subprocess, watchdog e argparse, com um organizador de pastas e um backup com rotação — e as armadilhas que não avisam, como os.system que não devolve o código de saída, pipe que trava o processo e arquivo lido pela metade.
Python

• • 19 min de leitura

Uma das razões pelas quais Python se tornou tão popular é sua capacidade de automatizar tarefas repetitivas do dia a dia — organizar arquivos, renomear em lote, monitorar pastas, manipular o sistema operacional, executar comandos no terminal. O que levaria horas de trabalho manual pode ser resolvido em dezenas de linhas de Python. Neste artigo veremos as principais ferramentas para isso.

os e os.path: Interface com o Sistema Operacional

import os

# Informações do sistema
print(os.name)           # 'posix' (Linux/Mac) ou 'nt' (Windows)
print(os.getcwd())       # diretório atual
print(os.getenv("HOME")) # variável de ambiente

# Navegação
os.chdir("/tmp")         # muda o diretório atual

# Listagem
for item in os.listdir("."):
    print(item)

# Criação e remoção
os.mkdir("nova_pasta")
os.makedirs("a/b/c", exist_ok=True)   # cria toda a árvore
os.rmdir("pasta_vazia")

# Renomeando e movendo
os.rename("antigo.txt", "novo.txt")   # se novo.txt existir: no Linux, sobrescreve calado;
                                    # no Windows, FileExistsError. os.replace sobrescreve nos dois

# Informações de arquivo
stat = os.stat("arquivo.txt")
print(stat.st_size)      # tamanho em bytes
print(stat.st_mtime)     # timestamp de modificação

# Executando comandos do sistema
codigo = os.system("ls -la")   # no Linux, status de espera: "exit 3" devolve 768
print(os.waitstatus_to_exitcode(codigo))   # o código de verdade (3.9+)

pathlib: A Forma Moderna

Já vimos pathlib no artigo Módulos, Pacotes e Organização de Projetos — aqui exploramos recursos mais avançados:

from pathlib import Path
from datetime import datetime
import shutil

pasta = Path("/home/usuario/documentos")

# Criando estrutura de pastas
(pasta / "projetos" / "python" / "2024").mkdir(parents=True, exist_ok=True)

# Informações detalhadas
arquivo = Path("relatorio.pdf")
if arquivo.exists():
    stat = arquivo.stat()
    tamanho_mb  = stat.st_size / 1024 / 1024
    modificado  = datetime.fromtimestamp(stat.st_mtime)
    print(f"Tamanho: {tamanho_mb:.2f} MB")
    print(f"Modificado: {modificado.strftime('%d/%m/%Y %H:%M')}")

# Glob — busca por padrão
pasta_atual = Path(".")

# Todos os .py recursivamente
for py in pasta_atual.rglob("*.py"):
    print(py)

# Apenas no nível atual
for txt in pasta_atual.glob("*.txt"):
    print(txt)

# Filtrando por tamanho
grandes = [
    f for f in pasta_atual.rglob("*")
    if f.is_file() and f.stat().st_size > 1024 * 1024   # > 1MB
]

# Leitura e escrita diretas
Path("config.txt").write_text("chave=valor\n", encoding="utf-8")
conteudo = Path("config.txt").read_text(encoding="utf-8")

shutil: Operações de Alto Nível

import shutil
from pathlib import Path

# Copiar arquivo
shutil.copy("origem.txt", "destino.txt")           # copia conteúdo
shutil.copy2("origem.txt", "destino.txt")          # copia + metadados

# Copiar pasta inteira
shutil.copytree("pasta_origem", "pasta_destino")   # destino existente: FileExistsError
shutil.copytree("pasta_origem", "pasta_destino", dirs_exist_ok=True)   # mescla (3.8+)

# Mover
shutil.move("arquivo.txt", "/outro/caminho/")

# Remover pasta com todo o conteúdo
shutil.rmtree("pasta_para_deletar")

# Compactar e descompactar
shutil.make_archive("backup_2024", "zip",  "pasta_fonte")
shutil.make_archive("backup_2024", "gztar", "pasta_fonte")  # .tar.gz
shutil.unpack_archive("backup_2024.zip", "destino")

# Espaço em disco
total, usado, livre = shutil.disk_usage("/")
print(f"Total: {total // (2**30)} GB")
print(f"Usado: {usado  // (2**30)} GB")
print(f"Livre: {livre  // (2**30)} GB")

# Encontrar executável no PATH
python_path = shutil.which("python3")
print(python_path)  # /usr/bin/python3

Organizador Automático de Arquivos

from pathlib import Path
from datetime import datetime
import shutil


REGRAS = {
    "Imagens":    [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp"],
    "Documentos": [".pdf", ".doc", ".docx", ".txt", ".md", ".odt"],
    "Planilhas":  [".xls", ".xlsx", ".csv", ".ods"],
    "Videos":     [".mp4", ".avi", ".mkv", ".mov", ".wmv"],
    "Audios":     [".mp3", ".wav", ".flac", ".aac", ".ogg"],
    "Codigo":     [".py", ".js", ".ts", ".html", ".css", ".json", ".yml"],
    "Compactados":[".zip", ".tar", ".gz", ".rar", ".7z"],
}


def organizar_pasta(caminho: str, simular: bool = True,
                    extensoes: list | None = None, destino: str | None = None):
    """
    Organiza arquivos de uma pasta em subpastas por tipo.
    simular=True apenas mostra o que faria, sem mover nada.
    """
    pasta   = Path(caminho)
    movidos = []
    ignorados = []

    for arquivo in pasta.iterdir():
        if not arquivo.is_file():
            continue
        if extensoes and arquivo.suffix.lower() not in extensoes:
            continue

        extensao  = arquivo.suffix.lower()
        categoria = None

        for cat, exts in REGRAS.items():
            if extensao in exts:
                categoria = cat
                break

        if not categoria:
            categoria = "Outros"

        destino_pasta  = (Path(destino).expanduser() if destino else pasta) / categoria
        destino_arquivo = destino_pasta / arquivo.name

        # Evita sobrescrever arquivos com mesmo nome
        if destino_arquivo.exists():
            stem  = arquivo.stem
            sufx  = arquivo.suffix
            ts    = datetime.now().strftime("%Y%m%d_%H%M%S")
            destino_arquivo = destino_pasta / f"{stem}_{ts}{sufx}"

        movidos.append({
            "arquivo":  arquivo.name,
            "de":       str(pasta),
            "para":     str(destino_pasta),
            "categoria": categoria
        })

        if not simular:
            destino_pasta.mkdir(parents=True, exist_ok=True)
            shutil.move(str(arquivo), str(destino_arquivo))

    # Relatório
    print(f"\n{'=== SIMULAÇÃO ===' if simular else '=== EXECUÇÃO ==='}")
    print(f"Pasta: {caminho}")
    print(f"Arquivos a mover: {len(movidos)}\n")

    from collections import Counter
    contagem = Counter(m["categoria"] for m in movidos)
    for cat, qtd in sorted(contagem.items()):
        print(f"  {cat:15}: {qtd} arquivo(s)")

    if not simular:
        print("\nArquivos movidos com sucesso.")

    return movidos


# Primeiro simula, depois executa
movimentos = organizar_pasta("/home/usuario/Downloads", simular=True)
# organizar_pasta("/home/usuario/Downloads", simular=False)

subprocess: Executando Comandos Externos

import subprocess


# Executar e capturar saída
resultado = subprocess.run(
    ["ls", "-la"],
    capture_output=True,
    text=True
)
print(resultado.stdout)
print(resultado.returncode)   # 0 = sucesso


# Com shell=True — permite pipes e redirecionamentos
resultado = subprocess.run(
    "ps aux | grep [p]ython | wc -l",   # sem o [p], conta o sh e o próprio grep
    shell=True,
    capture_output=True,
    text=True
)
print(f"Processos Python: {resultado.stdout.strip()}")


# Verificando erros
try:
    subprocess.run(
        ["git", "status"],
        capture_output=True,
        text=True,
        check=True    # lança CalledProcessError se returncode != 0
    )
except subprocess.CalledProcessError as e:
    print(f"Erro: {e.stderr}")
except FileNotFoundError:   # check=True não cobre executável ausente
    print("git não está instalado ou fora do PATH")


# Execução com timeout
try:
    resultado = subprocess.run(
        ["ping", "-c", "3", "google.com"],
        capture_output=True,
        text=True,
        timeout=10
    )
    print(resultado.stdout)
except subprocess.TimeoutExpired:
    print("Comando demorou demais.")


# Processo em background
processo = subprocess.Popen(
    ["uvicorn", "main:app", "--port", "8000"],
    # PIPE sem ninguém lendo trava o filho quando o buffer enche:
    # o log de cada requisição acaba parando o servidor
    stdout=open("servidor.log", "w"),
    stderr=subprocess.STDOUT
)
print(f"PID do servidor: {processo.pid}")
# processo.terminate()  # encerra quando necessário

Monitoramento de Pastas com watchdog

pip install watchdog
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from pathlib import Path
from datetime import datetime
import time
import shutil


class ProcessadorArquivos(FileSystemEventHandler):
    """Processa automaticamente arquivos novos em uma pasta."""

    def __init__(self, pasta_entrada: str, pasta_processado: str):
        self.pasta_entrada    = Path(pasta_entrada)
        self.pasta_processado = Path(pasta_processado)
        self.pasta_processado.mkdir(exist_ok=True)

    def on_created(self, event):
        if event.is_directory:
            return
        arquivo = Path(event.src_path)
        print(f"[{datetime.now().strftime('%H:%M:%S')}] Novo arquivo: {arquivo.name}")
        self._processar(arquivo)

    def on_modified(self, event):
        if event.is_directory:
            return
        arquivo = Path(event.src_path)
        print(f"[{datetime.now().strftime('%H:%M:%S')}] Modificado: {arquivo.name}")

    def on_deleted(self, event):
        print(f"[{datetime.now().strftime('%H:%M:%S')}] Deletado: {event.src_path}")

    def _processar(self, arquivo: Path):
        """Processa o arquivo conforme sua extensão."""
        time.sleep(0.5)   # não garante o arquivo completo: veja a nota abaixo

        if not arquivo.exists():
            return

        if arquivo.suffix.lower() == ".csv":
            self._processar_csv(arquivo)
        elif arquivo.suffix.lower() in [".jpg", ".png"]:
            self._processar_imagem(arquivo)
        else:
            self._mover_para_processados(arquivo, "outros")

    def _processar_csv(self, arquivo: Path):
        import csv
        try:
            with open(arquivo, encoding="utf-8") as f:
                reader  = csv.DictReader(f)
                linhas  = list(reader)
            print(f"  CSV processado: {len(linhas)} registros")
            self._mover_para_processados(arquivo, "csv")
        except Exception as e:
            print(f"  Erro ao processar CSV: {e}")

    def _processar_imagem(self, arquivo: Path):
        print(f"  Imagem recebida: {arquivo.stat().st_size / 1024:.1f} KB")
        self._mover_para_processados(arquivo, "imagens")

    def _mover_para_processados(self, arquivo: Path, subcategoria: str):
        destino = self.pasta_processado / subcategoria
        destino.mkdir(exist_ok=True)
        shutil.move(str(arquivo), str(destino / arquivo.name))
        print(f"  Movido para: {destino}")


def monitorar(pasta_entrada: str, pasta_saida: str):
    handler  = ProcessadorArquivos(pasta_entrada, pasta_saida)
    observer = Observer()
    observer.schedule(handler, pasta_entrada, recursive=False)
    observer.start()

    print(f"Monitorando: {pasta_entrada}")
    print("Pressione Ctrl+C para parar.\n")

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()


# monitorar("/home/usuario/entrada", "/home/usuario/processado")

O on_created avisa que o arquivo apareceu, não que terminou de ser escrito. Com um CSV gravado em duas partes, 1,5 segundo uma da outra — como num upload lento ou numa cópia de rede —, o handler acima leu 500 dos 1000 registros e moveu o arquivo pela metade, sem nenhum erro. Esperar meio segundo só troca o problema de lugar. O jeito robusto é combinar com quem produz o arquivo: gravar com um nome temporário (notas.csv.part) e renomear ao final, tratando o arquivo em on_moved, pelo event.dest_path. No Linux, o observador baseado em inotify também entrega on_closed, disparado quando quem escrevia fecha o arquivo.

Scripts com Argumentos: argparse

import argparse
from pathlib import Path


def criar_parser():
    parser = argparse.ArgumentParser(
        description="Organizador automático de arquivos",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Exemplos:
  python organizar.py ~/Downloads
  python organizar.py ~/Downloads --executar
  python organizar.py ~/Downloads --executar --verbose
  python organizar.py ~/Downloads --extensoes .pdf .doc
        """
    )

    parser.add_argument(
        "pasta",
        type=str,
        help="Caminho da pasta a organizar"
    )
    parser.add_argument(
        "--executar", "-e",
        action="store_true",
        default=False,
        help="Executa as mudanças (padrão: apenas simula)"
    )
    parser.add_argument(
        "--verbose", "-v",
        action="store_true",
        help="Exibe detalhes de cada arquivo"
    )
    parser.add_argument(
        "--extensoes",
        nargs="+",
        metavar="EXT",
        help="Filtra por extensões específicas (ex: .pdf .jpg)"
    )
    parser.add_argument(
        "--destino", "-d",
        type=str,
        default=None,
        help="Pasta de destino (padrão: subpastas dentro da origem)"
    )

    return parser


def main():
    parser = criar_parser()
    args   = parser.parse_args()

    pasta = Path(args.pasta)
    if not pasta.exists():
        parser.error(f"Pasta não encontrada: {args.pasta}")

    print(f"Pasta:    {pasta}")
    print(f"Modo:     {'EXECUÇÃO' if args.executar else 'SIMULAÇÃO'}")
    if args.extensoes:
        print(f"Filtro:   {', '.join(args.extensoes)}")

    extensoes  = ["." + e.lower().lstrip(".") for e in args.extensoes or []]
    movimentos = organizar_pasta(str(pasta), simular=not args.executar,
                                 extensoes=extensoes, destino=args.destino)

    if args.verbose:
        for m in movimentos:
            print(f"  {m['arquivo']:30} → {m['categoria']}")


if __name__ == "__main__":
    main()
# Uso
python organizar.py ~/Downloads
python organizar.py ~/Downloads --executar --verbose
python organizar.py ~/Downloads --extensoes .pdf .doc --destino ~/Documentos

Exemplo Completo: Script de Backup Automatizado

#!/usr/bin/env python3
"""
backup.py — Script de backup com compressão e rotação

Uso:
    python backup.py --origem ~/projetos --destino ~/backups
    python backup.py --origem ~/projetos --destino ~/backups --manter 7
"""
import argparse
import shutil
import os
from pathlib import Path
from datetime import datetime


def criar_backup(origem: Path, destino: Path, manter: int = 7) -> Path:
    """Cria backup comprimido e remove backups antigos."""

    destino.mkdir(parents=True, exist_ok=True)

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    nome_base = f"backup_{origem.name}_{timestamp}"
    caminho   = destino / nome_base

    print(f"[{datetime.now().strftime('%H:%M:%S')}] Criando backup...")
    print(f"  Origem:  {origem}")
    print(f"  Destino: {caminho}.zip")

    shutil.make_archive(str(caminho), "zip", str(origem.parent), str(origem.name))

    arquivo_zip = Path(f"{caminho}.zip")
    tamanho     = arquivo_zip.stat().st_size / 1024 / 1024
    print(f"  Tamanho: {tamanho:.2f} MB")
    print(f"  Status:  OK ✓")

    # Rotação — remove backups mais antigos
    backups = sorted(destino.glob(f"backup_{origem.name}_*.zip"))
    if len(backups) > manter:
        for antigo in backups[:-manter]:
            antigo.unlink()
            print(f"  Removido (rotação): {antigo.name}")

    return arquivo_zip


def main():
    parser = argparse.ArgumentParser(description="Script de backup automatizado")
    parser.add_argument("--origem",  required=True, help="Pasta a fazer backup")
    parser.add_argument("--destino", required=True, help="Onde salvar os backups")
    parser.add_argument("--manter",  type=int, default=7, help="Backups a manter (padrão: 7)")
    args = parser.parse_args()

    origem  = Path(args.origem).expanduser().resolve()
    destino = Path(args.destino).expanduser().resolve()

    if not origem.exists():
        print(f"Erro: origem não encontrada: {origem}")
        exit(1)

    if destino.is_relative_to(origem):   # cada backup levaria os anteriores
        print("Erro: o destino não pode ficar dentro da origem")
        exit(1)

    arquivo = criar_backup(origem, destino, args.manter)

    # Log de auditoria
    log = destino / "backup.log"
    with open(log, "a", encoding="utf-8") as f:
        f.write(f"{datetime.now().isoformat()} | {origem} | {arquivo} | OK\n")

    print(f"\nBackup concluído: {arquivo}")


if __name__ == "__main__":
    main()
# Executar manualmente
python backup.py --origem ~/projetos --destino ~/backups --manter 5

# Agendar com cron (Linux/Mac)
# crontab -e
# 0 2 * * * /usr/bin/python3 /home/usuario/backup.py --origem ~/projetos --destino ~/backups

Automatizar tarefas de arquivo em Python é escolher a camada certa para cada passo. O pathlib monta caminhos e lê metadados sem se preocupar com barra ou contrabarra; o shutil copia, move, compacta e mede o disco; o subprocess.run chama programas externos com saída capturada, código de retorno e tempo limite; o watchdog reage ao que aparece numa pasta; e o argparse transforma o script numa ferramenta que outra pessoa consegue usar sem abrir o código. Para rodar em horário fixo, o cron basta num servidor só; quando o agendamento precisa conviver com uma aplicação web e com filas, o Beat de Celery: Filas de Tarefas e Processamento Assíncrono ocupa esse lugar.

O risco desses scripts é que eles agem sobre arquivos de verdade, e quase nada avisa quando dá errado. os.rename sobrescreve no Linux sem perguntar, os.system devolve um número que parece código de saída e não é, um pipe que ninguém lê trava o processo filho, e um evento de arquivo criado chega antes do conteúdo. Por isso vale manter o modo de simulação como padrão, conferir o destino antes de mover ou compactar, e testar cada script numa pasta descartável antes de apontá-lo para os Downloads de alguém.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um script de deploy roda codigo = os.system("./migrar.sh") e depois consulta uma tabela: {1: "erro genérico", 2: "banco indisponível", 3: "migração parcial"}. Em produção, o migrar.sh terminou com exit 3, e o script reportou "código desconhecido: 768" e seguiu com o deploy. Explique o 768 e como obter o código de verdade.

Ver resposta

✓ Resposta: No Linux e no macOS, os.system não devolve o código de saída, e sim o status de espera do processo, no formato do wait() do sistema: o código ocupa o byte de cima. Medido: os.system("exit 3") devolve 768, que é 3 × 256. Quando o processo morre por um sinal, o número muda de novo, e o código de saída nem existe. A conversão correta é os.waitstatus_to_exitcode(codigo), disponível desde a 3.9, que devolveu 3 — e um número negativo quando o motivo é um sinal. No Windows, os.system devolve o código diretamente, então a mesma tabela funcionaria lá e falharia no servidor, o que explica o erro ter escapado dos testes. A solução melhor é não usar os.system: subprocess.run(["./migrar.sh"]) expõe o código em returncode nos dois sistemas, e com check=True o deploy para sozinho quando o código não é zero. O defeito de origem, porém, é seguir adiante com um código desconhecido: qualquer valor fora da tabela deveria interromper o deploy, não virar aviso.

Exercício 2

Um monitor verifica a cada minuto se o worker de importação está vivo com subprocess.run("ps aux | grep importador.py | wc -l", shell=True, ...) e alerta se o resultado for zero. O worker caiu numa sexta à noite, e o alerta nunca disparou. Na segunda, alguém rodou o comando à mão e viu 1. O que o monitor estava contando?

Ver resposta

✓ Resposta: A si mesmo. Com shell=True, o Python inicia um /bin/sh -c "ps aux | grep importador.py | wc -l", e esse processo tem o nome procurado na própria linha de comando; o grep importador.py também. Medido com o exemplo do artigo, ps aux | grep python: com um único processo Python de verdade, a saída tinha três linhas — o processo, o sh -c e o grep. No terminal, sem o sh -c, sobra só a linha do grep: por isso o 1 da segunda-feira. O monitor nunca poderia ver zero. A correção mínima é o truque do colchete, grep [i]mportador.py: a expressão casa com "importador.py", mas o texto do próprio comando contém "[i]mportador.py" e deixa de casar — medido, as linhas do sh e do grep sumiram da saída. A correção de verdade é não depender de texto: subprocess.run(["pgrep", "-f", "importador.py"]), chamado com lista, se exclui da busca e devolve código 1 quando não encontra nada. Mas atenção: medido, o mesmo pgrep com shell=True encontrou o próprio sh -c e devolveu 0, repetindo o defeito original. Um arquivo de PID ou o próprio gerenciador de serviços (systemctl is-active) respondem a pergunta sem ambiguidade.

Exercício 3

Um script de testes sobe a API com subprocess.Popen(["uvicorn", "main:app"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) e dispara requisições contra ela. Com poucos testes, tudo passa. Com a suíte inteira, os testes começam a dar timeout sempre depois de mais ou menos o mesmo número de requisições, e o processo do uvicorn continua vivo, sem consumir CPU. O que está acontecendo?

Ver resposta

✓ Resposta: O servidor está bloqueado escrevendo o próprio log. Um pipe tem um buffer finito no sistema operacional; quando ele enche e ninguém lê a outra ponta, a próxima escrita do processo filho fica esperando. O uvicorn registra cada requisição, então o buffer enche num número previsível de requisições, e a partir daí o processo para dentro de um write, vivo e sem CPU. Medido com um filho que escreve 10 MB em linhas no stderr: com stderr=PIPE e ninguém lendo, ele continuava travado três segundos depois; com DEVNULL, terminou normalmente. Há três saídas corretas: mandar a saída para um arquivo (stdout=open("servidor.log", "w"), stderr=subprocess.STDOUT), que ainda permite ler o log se um teste falhar; descartá-la com subprocess.DEVNULL; ou consumi-la numa thread. communicate() lê as duas pontas sem travar, mas espera o processo terminar, e por isso não serve para um servidor. A regra geral: só peça PIPE para o que você vai ler.

Exercício 4

Um parceiro envia CSVs por SFTP para uma pasta monitorada com o ProcessadorArquivos do artigo. Nos arquivos pequenos, tudo certo. Nos grandes, o sistema importa só parte dos registros, e o arquivo que sobra em processado/csv está completo quando alguém confere depois. O parceiro jura que envia tudo. Explique a importação pela metade e proponha uma solução que não dependa de ajustar o time.sleep.

Ver resposta

✓ Resposta: O on_created dispara quando o arquivo passa a existir, e o SFTP cria o arquivo antes de começar a transferir o conteúdo. O handler espera meio segundo, lê o que já chegou e move o arquivo — e o cliente SFTP continua escrevendo no mesmo arquivo, agora em outra pasta, porque no Linux mover dentro do mesmo sistema de arquivos não interrompe quem já o tem aberto. Medido com um CSV gravado em duas partes, 1,5 s uma da outra: o handler leu 500 dos 1000 registros, e o arquivo movido terminou com os 1000. Aumentar o sleep só muda o tamanho do arquivo que quebra. A solução é um protocolo de conclusão: o parceiro envia com nome temporário, como vendas.csv.part, e renomeia ao terminar — a maioria dos clientes SFTP tem essa opção —, e o handler ignora .part e processa em on_moved, usando event.dest_path. Sem controle sobre o parceiro, há duas alternativas: no Linux, o evento on_closed (medido: disparou quando a escrita terminou), ou esperar o tamanho do arquivo parar de mudar por alguns segundos. Em qualquer caso, a importação deve ser idempotente, porque um arquivo reenviado não pode duplicar registros.

Exercício 5

Um estagiário agendou no cron o backup.py do artigo com --origem ~/projetos --destino ~/projetos/backups, para "deixar tudo junto". Em um mês, o disco encheu, embora os projetos tenham 200 MB e o script mantenha só sete backups. Explique o crescimento e onde a validação deveria estar.

Ver resposta

✓ Resposta: Cada backup compacta a pasta de origem inteira — e a pasta de backups está dentro dela. O primeiro zip tem só os projetos; o segundo tem os projetos e o primeiro zip; o terceiro, os projetos e os dois anteriores. Medido com o próprio make_archive e o destino dentro da origem: os três arquivos tiveram 702, 1080 e 1695 bytes, e cada um continha todos os zips anteriores. A rotação apaga os antigos do disco, mas não de dentro dos novos, então o tamanho de cada backup cresce a cada execução, e sete deles somam muito mais do que sete cópias de 200 MB. A validação correta fica no início do script, antes de qualquer escrita: com os dois caminhos já normalizados por expanduser() e resolve(), destino.is_relative_to(origem) (3.9+) recusa a combinação, como o script corrigido faz. Normalizar primeiro é essencial: comparar os textos crus deixaria passar ~/projetos/../projetos/backups ou um link simbólico. Vale ainda um teto de sanidade — um backup muito maior que o anterior deveria gerar alerta, e não passar em silêncio.

Comentários

Mais em Python

Tuplas e Sets: imutabilidade e unicidade
Tuplas e Sets: imutabilidade e unicidade

Tuplas e sets parecem variações da lista, mas resolvem problemas distintos…

Autenticação e Segurança em APIs Python
Autenticação e Segurança em APIs Python

Senhas com bcrypt, API Keys, JWT e OAuth2 no FastAPI, e as brechas que o…

Funções: definição, parâmetros e escopo
Funções: definição, parâmetros e escopo

Nomear um bloco de código é o primeiro passo para escrever programas que…