Tiga bagian sebelumnya kita yakiki dengan mata: jalankan, baca keluaran, bandingkan. Cara itu rapuh — tidak ada yang mengulang pemeriksaan setiap kali kode berubah, dan calon pengguna tidak akan memverifikasi apa pun sebelum memakai program. Pada bagian penutup ini kita mengubah filetool dari skrip menjadi produk: perilakunya dikunci dengan 14 test pytest, lalu program dipaketkan sehingga terpasang sebagai perintah filetool sungguhan di sebuah virtual environment.

Logika yang Layak Diuji #

Test paling murah dan paling cepat adalah test terhadap fungsi murni — fungsi yang keluarannya hanya bergantung pada masukannya. Perancangan tiga bagian sebelumnya sudah menyiapkan kandidatnya:

FungsiPeranMengapa mudah diuji
format_size(ukuran)ubah byte menjadi tekssatu angka masuk, satu string keluar
parse_exclude(nilai)normalisasi pola eksklusibekerja pada string/list, tanpa disk
filter_files(daftar, pola)saring daftar Pathlist masuk, list keluar

Di latar belakang program tetap ada bagian yang menyentuh disk dan terminal. Untuk lapisan ini kita menguji lewat pintu depannya: fungsi main(argv) menerima list argumen, sehingga test bisa berpura-pura menjadi pengguna yang mengetik — teknik yang dipanggil lewat fixture tmp_path dan capsys milik pytest.

Lima test pertama menyasar fungsi murni tanpa menyiapkan apa pun:

python
"""Uji otomatis untuk filetool (main.py)."""

import json

import pytest

from main import filter_files, format_size, main, parse_exclude


# --- fungsi murni ---

def test_format_size_dalam_byte():
    assert format_size(0) == "0 B"
    assert format_size(15) == "15 B"
    assert format_size(1023) == "1023 B"


def test_format_size_satuan_besar():
    assert format_size(1536) == "1,5 KB"
    assert format_size(204800) == "200,0 KB"
    assert format_size(1048576) == "1,0 MB"


def test_parse_exclude_dari_string():
    assert parse_exclude(".git, __pycache__,*.tmp ") == [".git", "__pycache__", "*.tmp"]


def test_parse_exclude_kosong_dan_none():
    assert parse_exclude("") == []
    assert parse_exclude(None) == []
    assert parse_exclude([".git", " ", ""]) == [".git"]


def test_filter_files(tmp_path):
    (tmp_path / "buku.txt").touch()
    (tmp_path / "draf.tmp").touch()
    (tmp_path / "simpan.py").touch()
    hasil = filter_files(sorted(tmp_path.iterdir()), ["*.tmp"])
    assert hasil == [tmp_path / "buku.txt", tmp_path / "simpan.py"]
  1. Baris 7 (from main import ...): module main bisa diimpor seperti module biasa karena main.py berada satu folder dengan test_main.py — pytest menaruh folder tersebut ke sys.path saat mengumpulkan test.
  2. Baris 12 (def test_format_size_dalam_byte():): nama test adalah kalimat spesifikasi; siapa pun yang membaca nama-namanya tahu perilaku apa saja yang dijanjikan program.
  3. Baris 34 (def test_filter_files(tmp_path)): fixture tmp_path bawaan pytest menyiapkan folder sementara kosong untuk setiap test; tiga berkas dibuat, satu disaring keluar, dua sisanya harus kembali berurutan.

Sembilan test berikutnya memasuki program dari pintu depan melalui fixture sandbox, yang memindahkan direktori kerja test ke folder sementara dan membersihkan environment variable FILETOOL_CONFIG agar konfigurasi di luar tidak membocorkan diri ke dalam test:

python
# --- perintah lewat main(argv) ---

@pytest.fixture()
def sandbox(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.delenv("FILETOOL_CONFIG", raising=False)
    (tmp_path / "buku.txt").write_text("isi buku")
    (tmp_path / "draf.tmp").write_text("draf")
    return tmp_path


def test_daftar_menampilkan_isi(sandbox, capsys):
    assert main(["daftar", str(sandbox)]) == 0
    keluar = capsys.readouterr().out
    assert "buku.txt" in keluar
    assert "draf.tmp" in keluar


def test_cari_menghormati_kecuali(sandbox, capsys):
    (sandbox / "lagu.mp3").touch()
    assert main(["cari", str(sandbox), "--pola", "*", "--kecuali", "buku.*"]) == 0
    keluar = capsys.readouterr().out
    assert "lagu.mp3" in keluar
    # buku.txt hilang karena --kecuali; draf.tmp karena pola bawaan "*.tmp"
    assert "buku.txt" not in keluar
    assert "draf.tmp" not in keluar


def test_salin_ke_direktori(sandbox, capsys):
    (sandbox / "folder").mkdir()
    assert main(["salin", "buku.txt", "folder"]) == 0
    assert (sandbox / "folder" / "buku.txt").read_text() == "isi buku"


def test_hapus_tanpa_ya_menolak(sandbox, capsys):
    assert main(["hapus", "buku.txt"]) == 1
    assert "menolak" in capsys.readouterr().out
    assert (sandbox / "buku.txt").exists()


def test_hapus_default_menghapus_permanen(sandbox, capsys):
    assert main(["hapus", "buku.txt", "--ya"]) == 0
    assert not (sandbox / "buku.txt").exists()
    assert not (sandbox / ".filetool-sampah").exists()


def test_hapus_dengan_sampah(sandbox, capsys):
    (sandbox / "config.json").write_text(json.dumps({"gunakan_sampah": True}))
    assert main(["hapus", "buku.txt", "--ya"]) == 0
    assert not (sandbox / "buku.txt").exists()
    assert (sandbox / ".filetool-sampah" / "buku.txt").read_text() == "isi buku"


def test_ukuran_menghormati_kedalaman(sandbox, capsys):
    (sandbox / "config.json").write_text(json.dumps({"kedalaman_rekursif": 1}))
    pohon = sandbox / "pohon"
    (pohon / "dalam").mkdir(parents=True)
    (pohon / "dekat.txt").write_text("x" * 100)
    (pohon / "dalam" / "jauh.txt").write_text("y" * 50)
    assert main(["ukuran", "pohon"]) == 0
    keluar = capsys.readouterr().out
    # hanya dekat.txt (tingkat 1) yang dihitung; jauh.txt ada di tingkat 2
    assert "100 byte" in keluar
    assert "berkas   : 1" in keluar


def test_perintah_tak_dikenal_keluar_2(capsys):
    with pytest.raises(SystemExit) as info:
        main(["bukan-perintah"])
    assert info.value.code == 2
    assert "invalid choice" in capsys.readouterr().err


def test_versi_keluar_0(capsys):
    with pytest.raises(SystemExit) as info:
        main(["--versi"])
    assert info.value.code == 0
    assert "filetool" in capsys.readouterr().out
  1. Baris 5-6 (monkeypatch.chdir / delenv): fixture monkeypatch mengubah lingkungan sementara — direktori kerja berpindah ke tmp_path, dan environment variable FILETOOL_CONFIG dihapus bila ada; semuanya dipulihkan otomatis setelah test selesai.
  2. Baris 13 (main(["daftar", str(sandbox)]) == 0): parameter argv pada main() (dibangun sejak Bagian 1) akhirnya terbayar — test memanggil program persis seperti terminal memanggilnya, tanpa proses baru dan tanpa menyentuh folder asli kita.
  3. Baris 14 (capsys.readouterr().out): fixture capsys menangkap stdout dan stderr selama pemanggilan; method readouterr() mengosongkan buffer sekaligus mengembalikan isinya untuk diperiksa.
  4. Baris 41-42 (test_hapus_default_menghapus_permanen): pengujian perilaku bawaan tanpa config.json — berkas lenyap dan folder sampah tidak pernah dibuat.
  5. Baris 47-48 (test_hapus_dengan_sampah): satu bedanya dari test sebelumnya hanyalah config.json yang ditulis ke dalam sandbox; sisa pernyataannya mengunci cabang sampah pada perintah_hapus().
  6. Baris 67-71 (pytest.raises(SystemExit)): argparse mengakhiri program lewat exception SystemExit, bukan nilai balik — pytest menangkap exception tersebut dengan konteks manager pytest.raises() lalu memeriksa code-nya.

Menjalankan pytest #

Seluruh berkas dijalankan dengan satu perintah dari folder proyek:

bash
python3 -m pytest test_main.py -v

Output yang Dihasilkan:

text
============================= test session starts ==============================
platform linux -- Python 3.12.15, pytest-9.0.2, pluggy-1.6.0 -- /home/z/.venv/bin/python3
cachedir: .pytest_cache

test_main.py::test_format_size_dalam_byte PASSED                         [  7%]
test_main.py::test_format_size_satuan_besar PASSED                       [ 14%]
test_main.py::test_parse_exclude_dari_string PASSED                      [ 21%]
test_main.py::test_parse_exclude_kosong_dan_none PASSED                  [ 28%]
test_main.py::test_filter_files PASSED                                   [ 35%]
test_main.py::test_daftar_menampilkan_isi PASSED                         [ 42%]
test_main.py::test_cari_menghormati_kecuali PASSED                       [ 50%]
test_main.py::test_salin_ke_direktori PASSED                             [ 57%]
test_main.py::test_hapus_tanpa_ya_menolak PASSED                         [ 64%]
test_main.py::test_hapus_default_menghapus_permanen PASSED               [ 71%]
test_main.py::test_hapus_dengan_sampah PASSED                            [ 78%]
test_main.py::test_ukuran_menghormati_kedalaman PASSED                   [ 85%]
test_main.py::test_perintah_tak_dikenal_keluar_2 PASSED                  [ 92%]
test_main.py::test_versi_keluar_0 PASSED                                [100%]

============================== 14 passed in 0.10s ==============================

Keempat belas test lolos dalam sepersepuluh detik. Mulai sekarang, setiap kali kita mengubah main.py, satu perintah yang sama memberi tahu dalam hitungan detik apakah kontrak program masih terpenuhi.

pyproject.toml dan Entry Point #

Distribusi dimulai dari satu berkas pyproject.toml di samping main.py:

toml
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

[project]
name = "pengelola-file"
version = "1.0.0"
description = "CLI pengelola file: daftar, cari, salin, pindahkan, hapus, ukuran, dan organisasi."
requires-python = ">=3.10"

[project.scripts]
filetool = "main:main"

[tool.setuptools]
py-modules = ["main"]
  1. Baris 1-3 ([build-system]): build system dipisahkan dari kode — pip membuat lingkungan build sendiri lalu memanggil setuptools untuk membangun package.
  2. Baris 5-9 ([project]): metadata package; requires-python = ">=3.10" mencatat bahwa kita memakai union type seperti dict | None pada anotasi fungsi — sintaks PEP 604 yang hadir sejak Python 3.10.
  3. Baris 11-12 ([project.scripts]): inti distribusi CLI — pasangan filetool = "main:main" berarti “buat perintah filetool yang menjalankan fungsi main() di dalam module main”.
  4. Baris 14-15 (py-modules = ["main"]): proyek ini berupa satu berkas, bukan folder package; deklarasi py-modules memberi tahu setuptools bahwa module tingkat atas bernama main harus ikut terpasang.

Pilihan layout di sini perlu dijelaskan. Pendekatan umum adalah folder src/ berisi package (misalnya src/pengelola_file/cli.py), dan pendekatan itu memang tepat untuk proyek multi-modul. filetool kita adalah satu berkas tunggal, sehingga layout datar dengan py-modules lebih jujur terhadap bentuknya: tidak ada folder kosong yang dipelihara hanya demi formalitas.

Sebelum dipasang, nilai VERSI di main.py kita turunkan menjadi "1.0.0" — mengikuti version pada pyproject.toml, karena nomor tersebut menandai rilis pertama package, bukan hitungan bagian tutorial. Dua tempat ini memang harus disamakan secara manual; beberapa proyek mengotomatiskannya lewat single-sourcing versi, tetapi untuk satu berkas, penggandaan dua baris lebih sederhana daripada alat tambahannya.

Distribusi juga membawa satu realisasi baru: keluaran program akan di-pipe. Perintah seperti filetool daftar files | head -3 membuat head menutup pipa lebih awal, dan print() kita kemudian gagal dengan BrokenPipeError. Karena itu satu cabang except baru ditambahkan paling depan di blok try milik main():

python
def main(argv: list[str] | None = None) -> int:
    args = buat_parser().parse_args(argv)
    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.WARNING,
        format="[%(levelname)s] %(message)s",
    )
    config = muat_konfig()
    if config is None:
        return 1
    try:
        return args.func(args, config)
    except BrokenPipeError:
        # Pembaca pipa (misalnya `head`) menutup keluaran lebih awal; bukan galat.
        os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
        return 0
    except FileNotFoundError as e:
        print(f"filetool: tidak ditemukan: {e.filename or e}", file=sys.stderr)
        return 1
    except NotADirectoryError as e:
        print(f"filetool: bukan direktori: {e.filename or e}", file=sys.stderr)
        return 1
    except PermissionError as e:
        print(f"filetool: izin ditolak: {e.filename or e}", file=sys.stderr)
        return 1

Pemanggilan os.dup2() mengalihkan stdout ke berkas /dev/null sehingga interpreter Python tidak lagi mencoba menulis ke pipa yang sudah tertutup saat proses berakhir, dan main() melaporkan sukses — memotong keluaran dengan head adalah keputusan pengguna, bukan kegagalan program.

Pemasangan dalam Virtual Environment #

Uji coba pemasangan dilakukan di virtual environment sekali pakai agar lingkungan utama tetap bersih:

bash
python3 -m venv .venv-test
.venv-test/bin/pip install -e .

Output yang Dihasilkan (baris progres dipendekkan):

text
Obtaining file:///home/z/my-project/scripts/code-tests/3-e/stage4
  Installing build dependencies: started
  ...
Building wheels for collected packages: pengelola-file
  Building editable for pengelola-file (pyproject.toml): started
  ...
Successfully built pengelola-file
Installing collected packages: pengelola-file
Successfully installed pengelola-file-1.0.0

Flag -e memasang proyek dalam mode editable: perubahan pada main.py langsung terlihat tanpa memasang ulang — alasan utama kita tidak perlu sinkronisasi berkas saat terus mengembangkan. Metadata yang terpasang bisa diperiksa dengan pip show:

text
Name: pengelola-file
Version: 1.0.0
Summary: CLI pengelola file: daftar, cari, salin, pindahkan, hapus, ukuran, dan organisasi.

Sekarang perintah filetool hidup di .venv-test/bin/. pip membuat berkas pembungkus kecil yang isinya persis seperti ini:

python
#!/home/z/my-project/scripts/code-tests/3-e/stage4/.venv-test/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from main import main
if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
    sys.exit(main())

Berkas pembungkus inilah wujud nyata entry point: interpreter venv menjalankan fungsi main() dari module main, lalu meneruskan nilai baliknya ke sys.exit(). Jalankan tiga perintah berikut dari folder yang sama dengan pohon latihan:

bash
.venv-test/bin/filetool --versi
.venv-test/bin/filetool daftar files | head -3
.venv-test/bin/filetool ukuran files

Output yang Dihasilkan:

text
filetool 1.0.0
text
.git/
__pycache__/
arsip-lama.zip
text
total    : 1.468.086 byte (1,4 MB)
berkas   : 10
direktori: 2

Pemanggilan kedua sengaja melewati head -3: keluaran terpotong rapi tanpa traceback, bukti handler BrokenPipeError bekerja pada perintah yang benar-benar terpasang.

Empat bagian perjalanan kita tutup di sini: filetool berangkat dari parser argparse dengan tiga perintah baca, tumbuh menjadi pengelola file yang menulis dengan pengaman dry-run dan konfirmasi, menghormati konfigurasi per proyek dengan galat yang seragam, dan akhirnya terkunci oleh 14 test serta terpasang sebagai perintah sungguhan. Pola yang kita pakai — main(argv), logika murni yang terpisah, exit code yang disiplin, dan pyproject.toml — adalah pola yang sama yang dipakai alat-alat Python besar di luar sana; hanya skala dan jumlah fiturnya yang berbeda.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

main.py

Unduh berkas
python
"""filetool: CLI pengelola file sederhana."""

import argparse
import fnmatch
import json
import logging
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path

VERSI = "1.0.0"

KONFIG_BAWAAN = {
    "pola_eksklusi": [".git", "__pycache__", "*.tmp", "*~"],
    "kedalaman_rekursif": 5,
    "gunakan_sampah": False,
    "folder_sampah": ".filetool-sampah",
}


def format_size(ukuran: int) -> str:
    """Ubah ukuran dalam byte menjadi teks yang mudah dibaca manusia."""
    if ukuran < 1024:
        return f"{ukuran} B"
    for satuan in ("KB", "MB", "GB", "TB"):
        ukuran /= 1024
        if ukuran < 1024:
            return f"{ukuran:.1f} {satuan}".replace(".", ",")
    return f"{ukuran:.1f} PB".replace(".", ",")


def format_waktu(epoch: float) -> str:
    """Ubah stempel waktu epoch menjadi teks tanggal dan jam."""
    return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M")


def parse_exclude(nilai: str | list[str] | None) -> list[str]:
    """Normalisasi pola eksklusi dari string terpisah koma atau list."""
    if not nilai:
        return []
    kandidat = nilai.split(",") if isinstance(nilai, str) else list(nilai)
    return [pola.strip() for pola in kandidat if pola.strip()]


def cocok_pola(nama: str, pola: list[str]) -> bool:
    """Cek apakah sebuah nama cocok dengan pola fnmatch mana pun."""
    return any(fnmatch.fnmatch(nama, satu_pola) for satu_pola in pola)


def filter_files(daftar: list[Path], pola: list[str]) -> list[Path]:
    """Saring path yang namanya cocok pola eksklusi mana pun."""
    return [p for p in daftar if not cocok_pola(p.name, pola)]


def muat_konfig() -> dict | None:
    """Muat konfigurasi berlapis: nilai bawaan, lalu berkas JSON bila ada."""
    config = dict(KONFIG_BAWAAN)
    jalur = os.environ.get("FILETOOL_CONFIG", "config.json")
    berkas = Path(jalur)
    if not berkas.exists():
        if os.environ.get("FILETOOL_CONFIG"):
            print(f"filetool: konfigurasi tidak ditemukan: {berkas}", file=sys.stderr)
            return None
        logging.debug("tidak ada berkas konfigurasi; memakai nilai bawaan")
        return config
    try:
        with berkas.open(encoding="utf-8") as f:
            data = json.load(f)
    except PermissionError:
        print(f"filetool: izin ditolak saat membaca: {berkas}", file=sys.stderr)
        return None
    except json.JSONDecodeError as e:
        print(f"filetool: konfigurasi bukan JSON valid: {berkas} "
              f"(baris {e.lineno}, kolom {e.colno})", file=sys.stderr)
        return None
    if not isinstance(data, dict):
        print(f"filetool: akar konfigurasi harus objek: {berkas}", file=sys.stderr)
        return None
    config.update(data)
    logging.debug("konfigurasi dimuat dari %s", berkas)
    return config


def telusuri(akar: Path, config: dict) -> tuple[list[Path], list[Path]]:
    """Kumpulkan (berkas, direktori anak) secara rekursif, hormati eksklusi."""
    hasil_berkas: list[Path] = []
    hasil_direktori: list[Path] = []
    pola = parse_exclude(config.get("pola_eksklusi"))
    kedalaman = config.get("kedalaman_rekursif", 5)

    def jalan(direktori: Path, tingkat: int) -> None:
        if tingkat > kedalaman:
            logging.debug("berhenti sebelum %s (batas kedalaman %d)", direktori, kedalaman)
            return
        for entri in sorted(direktori.iterdir()):
            if cocok_pola(entri.name, pola):
                logging.debug("lewati %s (cocok pola eksklusi)", entri)
                continue
            if entri.is_dir():
                hasil_direktori.append(entri)
                jalan(entri, tingkat + 1)
            else:
                hasil_berkas.append(entri)

    jalan(akar, 1)
    return hasil_berkas, hasil_direktori


def perintah_daftar(args: argparse.Namespace, config: dict) -> int:
    direktori = Path(args.path)
    for entri in sorted(direktori.iterdir()):
        penanda = "d" if entri.is_dir() else "-"
        if args.panjang:
            stat = entri.stat()
            ukuran = "-" if entri.is_dir() else format_size(stat.st_size)
            print(f"{penanda} {ukuran:>8}  {format_waktu(stat.st_mtime)}  {entri.name}")
        else:
            akhiran = "/" if entri.is_dir() else ""
            print(f"{entri.name}{akhiran}")
    return 0


def perintah_info(args: argparse.Namespace, config: dict) -> int:
    berkas = Path(args.path)
    stat = berkas.stat()
    jenis = "direktori" if berkas.is_dir() else "berkas"
    baris = [
        ("jalur", str(berkas)),
        ("jenis", jenis),
    ]
    if berkas.is_dir():
        baris.append(("isi", f"{len(list(berkas.iterdir()))} entri"))
    else:
        baris.append(("ekstensi", berkas.suffix or "(tidak ada)"))
        baris.append(("ukuran", f"{stat.st_size} byte ({format_size(stat.st_size)})"))
    baris.append(("diubah", format_waktu(stat.st_mtime)))
    for label, nilai in baris:
        print(f"{label:<9}: {nilai}")
    return 0


def perintah_cari(args: argparse.Namespace, config: dict) -> int:
    akar = Path(args.path)
    pola = parse_exclude(config.get("pola_eksklusi")) + parse_exclude(args.kecuali)
    berkas_semua, _ = telusuri(akar, {**config, "pola_eksklusi": pola})
    hasil = sorted(b for b in berkas_semua if fnmatch.fnmatch(b.name, args.pola))
    if not hasil:
        print("tidak ada yang cocok.")
        return 0
    for jalur in hasil:
        print(jalur.relative_to(akar))
    return 0


def perintah_salin(args: argparse.Namespace, config: dict) -> int:
    sumber = Path(args.sumber)
    tujuan = Path(args.tujuan)
    if sumber.is_dir():
        print(f"filetool: sumber harus berkas, bukan direktori: {sumber}", file=sys.stderr)
        return 1
    if tujuan.is_dir():
        tujuan = tujuan / sumber.name
    if tujuan.exists():
        print(f"filetool: tujuan sudah ada: {tujuan}", file=sys.stderr)
        return 1
    shutil.copy2(sumber, tujuan)
    print(f"tersalin: {sumber} -> {tujuan}")
    return 0


def perintah_pindahkan(args: argparse.Namespace, config: dict) -> int:
    sumber = Path(args.sumber)
    tujuan = Path(args.tujuan)
    if tujuan.is_dir():
        tujuan = tujuan / sumber.name
    if tujuan.exists():
        print(f"filetool: tujuan sudah ada: {tujuan}", file=sys.stderr)
        return 1
    shutil.move(sumber, tujuan)
    print(f"dipindahkan: {sumber} -> {tujuan}")
    return 0


def perintah_hapus(args: argparse.Namespace, config: dict) -> int:
    target = Path(args.path)
    if not target.is_file():
        print(f"filetool: bukan berkas: {target}", file=sys.stderr)
        return 1
    if not args.ya:
        print(f"menolak menghapus tanpa --ya: {target}")
        print("jalankan ulang dengan --ya untuk benar-benar menghapus.")
        return 1
    if config.get("gunakan_sampah"):
        folder_sampah = Path(config.get("folder_sampah", ".filetool-sampah"))
        folder_sampah.mkdir(exist_ok=True)
        tujuan = folder_sampah / target.name
        if tujuan.exists():
            print(f"filetool: sudah ada di sampah: {tujuan}", file=sys.stderr)
            return 1
        shutil.move(target, tujuan)
        print(f"ke sampah: {target} -> {tujuan}")
        return 0
    target.unlink()
    print(f"terhapus: {target}")
    return 0


def perintah_ukuran(args: argparse.Namespace, config: dict) -> int:
    akar = Path(args.path)
    daftar_berkas, daftar_direktori = telusuri(akar, config)
    total = sum(b.stat().st_size for b in daftar_berkas)
    mentah = f"{total:,}".replace(",", ".")
    print(f"{'total':<9}: {mentah} byte ({format_size(total)})")
    print(f"{'berkas':<9}: {len(daftar_berkas)}")
    print(f"{'direktori':<9}: {len(daftar_direktori)}")
    return 0


def susun_rencana(akar: Path, pola: list[str]) -> list[tuple[Path, Path]]:
    """Rancang pemindahan setiap berkas ke folder sesuai ekstensinya."""
    rencana = []
    for berkas in filter_files(sorted(akar.iterdir()), pola):
        if not berkas.is_file():
            continue
        folder = berkas.suffix.lstrip(".") or "tanpa-ekstensi"
        rencana.append((berkas, akar / folder / berkas.name))
    return rencana


def perintah_organisasi(args: argparse.Namespace, config: dict) -> int:
    akar = Path(args.path)
    pola = parse_exclude(config.get("pola_eksklusi")) + parse_exclude(args.kecuali)
    rencana = susun_rencana(akar, pola)
    if not rencana:
        print("tidak ada berkas yang perlu diorganisasi.")
        return 0
    for sumber, tujuan in rencana:
        print(f"{sumber} -> {tujuan}")
    if args.dry_run:
        print(f"(dry-run: {len(rencana)} berkas akan dipindahkan)")
        return 0
    for sumber, tujuan in rencana:
        tujuan.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(sumber, tujuan)
    print(f"{len(rencana)} berkas dipindahkan.")
    return 0


def buat_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="filetool",
        description="Pengelola file dari terminal.",
    )
    parser.add_argument("--versi", action="version", version=f"filetool {VERSI}")
    parser.add_argument("--verbose", action="store_true",
                        help="tampilkan pesan debug yang lebih rinci")
    sub = parser.add_subparsers(dest="perintah", required=True, metavar="PERINTAH")

    p_daftar = sub.add_parser("daftar", help="tampilkan isi sebuah direktori")
    p_daftar.add_argument("path", help="direktori yang ingin ditampilkan")
    p_daftar.add_argument("--panjang", action="store_true",
                          help="tampilkan ukuran, waktu ubah, dan nama")
    p_daftar.set_defaults(func=perintah_daftar)

    p_info = sub.add_parser("info", help="tampilkan metadata sebuah berkas")
    p_info.add_argument("path", help="berkas atau direktori yang ingin diperiksa")
    p_info.set_defaults(func=perintah_info)

    p_cari = sub.add_parser("cari", help="cari berkas berdasarkan pola glob")
    p_cari.add_argument("path", help="direktori awal pencarian")
    p_cari.add_argument("--pola", default="*", help='pola nama berkas, misalnya "*.txt"')
    p_cari.add_argument("--kecuali", default="",
                        help="pola tambahan yang diabaikan, dipisah koma")
    p_cari.set_defaults(func=perintah_cari)

    p_salin = sub.add_parser("salin", help="salin sebuah berkas")
    p_salin.add_argument("sumber", help="berkas sumber")
    p_salin.add_argument("tujuan", help="berkas atau direktori tujuan")
    p_salin.set_defaults(func=perintah_salin)

    p_pindahkan = sub.add_parser("pindahkan", help="pindahkan berkas atau direktori")
    p_pindahkan.add_argument("sumber", help="berkas atau direktori sumber")
    p_pindahkan.add_argument("tujuan", help="berkas atau direktori tujuan")
    p_pindahkan.set_defaults(func=perintah_pindahkan)

    p_hapus = sub.add_parser("hapus", help="hapus sebuah berkas")
    p_hapus.add_argument("path", help="berkas yang akan dihapus")
    p_hapus.add_argument("--ya", action="store_true",
                         help="konfirmasi bahwa pengguna yakin ingin menghapus")
    p_hapus.set_defaults(func=perintah_hapus)

    p_ukuran = sub.add_parser("ukuran", help="hitung total ukuran secara rekursif")
    p_ukuran.add_argument("path", help="direktori yang ingin diukur")
    p_ukuran.set_defaults(func=perintah_ukuran)

    p_organisasi = sub.add_parser("organisasi", help="urutkan berkas ke folder per ekstensi")
    p_organisasi.add_argument("path", help="direktori yang akan diorganisasi")
    p_organisasi.add_argument("--dry-run", action="store_true",
                              help="tampilkan rencana tanpa memindahkan apa pun")
    p_organisasi.add_argument("--kecuali", default="",
                              help="pola tambahan yang diabaikan, dipisah koma")
    p_organisasi.set_defaults(func=perintah_organisasi)

    return parser


def main(argv: list[str] | None = None) -> int:
    args = buat_parser().parse_args(argv)
    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.WARNING,
        format="[%(levelname)s] %(message)s",
    )
    config = muat_konfig()
    if config is None:
        return 1
    try:
        return args.func(args, config)
    except BrokenPipeError:
        # Pembaca pipa (misalnya `head`) menutup keluaran lebih awal; bukan galat.
        os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
        return 0
    except FileNotFoundError as e:
        print(f"filetool: tidak ditemukan: {e.filename or e}", file=sys.stderr)
        return 1
    except NotADirectoryError as e:
        print(f"filetool: bukan direktori: {e.filename or e}", file=sys.stderr)
        return 1
    except PermissionError as e:
        print(f"filetool: izin ditolak: {e.filename or e}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())

pyproject.toml

Unduh berkas
toml
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

[project]
name = "pengelola-file"
version = "1.0.0"
description = "CLI pengelola file: daftar, cari, salin, pindahkan, hapus, ukuran, dan organisasi."
requires-python = ">=3.10"

[project.scripts]
filetool = "main:main"

[tool.setuptools]
py-modules = ["main"]

test_main.py

Unduh berkas
python
"""Uji otomatis untuk filetool (main.py)."""

import json

import pytest

from main import filter_files, format_size, main, parse_exclude


# --- fungsi murni ---

def test_format_size_dalam_byte():
    assert format_size(0) == "0 B"
    assert format_size(15) == "15 B"
    assert format_size(1023) == "1023 B"


def test_format_size_satuan_besar():
    assert format_size(1536) == "1,5 KB"
    assert format_size(204800) == "200,0 KB"
    assert format_size(1048576) == "1,0 MB"


def test_parse_exclude_dari_string():
    assert parse_exclude(".git, __pycache__,*.tmp ") == [".git", "__pycache__", "*.tmp"]


def test_parse_exclude_kosong_dan_none():
    assert parse_exclude("") == []
    assert parse_exclude(None) == []
    assert parse_exclude([".git", " ", ""]) == [".git"]


def test_filter_files(tmp_path):
    (tmp_path / "buku.txt").touch()
    (tmp_path / "draf.tmp").touch()
    (tmp_path / "simpan.py").touch()
    hasil = filter_files(sorted(tmp_path.iterdir()), ["*.tmp"])
    assert hasil == [tmp_path / "buku.txt", tmp_path / "simpan.py"]


# --- perintah lewat main(argv) ---

@pytest.fixture()
def sandbox(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.delenv("FILETOOL_CONFIG", raising=False)
    (tmp_path / "buku.txt").write_text("isi buku")
    (tmp_path / "draf.tmp").write_text("draf")
    return tmp_path


def test_daftar_menampilkan_isi(sandbox, capsys):
    assert main(["daftar", str(sandbox)]) == 0
    keluar = capsys.readouterr().out
    assert "buku.txt" in keluar
    assert "draf.tmp" in keluar


def test_cari_menghormati_kecuali(sandbox, capsys):
    (sandbox / "lagu.mp3").touch()
    assert main(["cari", str(sandbox), "--pola", "*", "--kecuali", "buku.*"]) == 0
    keluar = capsys.readouterr().out
    assert "lagu.mp3" in keluar
    # buku.txt hilang karena --kecuali; draf.tmp karena pola bawaan "*.tmp"
    assert "buku.txt" not in keluar
    assert "draf.tmp" not in keluar


def test_salin_ke_direktori(sandbox, capsys):
    (sandbox / "folder").mkdir()
    assert main(["salin", "buku.txt", "folder"]) == 0
    assert (sandbox / "folder" / "buku.txt").read_text() == "isi buku"


def test_hapus_tanpa_ya_menolak(sandbox, capsys):
    assert main(["hapus", "buku.txt"]) == 1
    assert "menolak" in capsys.readouterr().out
    assert (sandbox / "buku.txt").exists()


def test_hapus_default_menghapus_permanen(sandbox, capsys):
    assert main(["hapus", "buku.txt", "--ya"]) == 0
    assert not (sandbox / "buku.txt").exists()
    assert not (sandbox / ".filetool-sampah").exists()


def test_hapus_dengan_sampah(sandbox, capsys):
    (sandbox / "config.json").write_text(json.dumps({"gunakan_sampah": True}))
    assert main(["hapus", "buku.txt", "--ya"]) == 0
    assert not (sandbox / "buku.txt").exists()
    assert (sandbox / ".filetool-sampah" / "buku.txt").read_text() == "isi buku"


def test_ukuran_menghormati_kedalaman(sandbox, capsys):
    (sandbox / "config.json").write_text(json.dumps({"kedalaman_rekursif": 1}))
    pohon = sandbox / "pohon"
    (pohon / "dalam").mkdir(parents=True)
    (pohon / "dekat.txt").write_text("x" * 100)
    (pohon / "dalam" / "jauh.txt").write_text("y" * 50)
    assert main(["ukuran", "pohon"]) == 0
    keluar = capsys.readouterr().out
    # hanya dekat.txt (tingkat 1) yang dihitung; jauh.txt ada di tingkat 2
    assert "100 byte" in keluar
    assert "berkas   : 1" in keluar


def test_perintah_tak_dikenal_keluar_2(capsys):
    with pytest.raises(SystemExit) as info:
        main(["bukan-perintah"])
    assert info.value.code == 2
    assert "invalid choice" in capsys.readouterr().err


def test_versi_keluar_0(capsys):
    with pytest.raises(SystemExit) as info:
        main(["--versi"])
    assert info.value.code == 0
    assert "filetool" in capsys.readouterr().out