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:
| Fungsi | Peran | Mengapa mudah diuji |
|---|---|---|
format_size(ukuran) | ubah byte menjadi teks | satu angka masuk, satu string keluar |
parse_exclude(nilai) | normalisasi pola eksklusi | bekerja pada string/list, tanpa disk |
filter_files(daftar, pola) | saring daftar Path | list 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.
Menulis test_main.py #
Lima test pertama menyasar fungsi murni tanpa menyiapkan apa pun:
"""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"]- Baris 7 (
from main import ...): modulemainbisa diimpor seperti module biasa karenamain.pyberada satu folder dengantest_main.py— pytest menaruh folder tersebut kesys.pathsaat mengumpulkan test. - 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. - Baris 34 (
def test_filter_files(tmp_path)): fixturetmp_pathbawaan 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:
# --- 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- Baris 5-6 (
monkeypatch.chdir / delenv): fixturemonkeypatchmengubah lingkungan sementara — direktori kerja berpindah ketmp_path, dan environment variableFILETOOL_CONFIGdihapus bila ada; semuanya dipulihkan otomatis setelah test selesai. - Baris 13 (
main(["daftar", str(sandbox)]) == 0): parameterargvpadamain()(dibangun sejak Bagian 1) akhirnya terbayar — test memanggil program persis seperti terminal memanggilnya, tanpa proses baru dan tanpa menyentuh folder asli kita. - Baris 14 (
capsys.readouterr().out): fixturecapsysmenangkapstdoutdanstderrselama pemanggilan; methodreadouterr()mengosongkan buffer sekaligus mengembalikan isinya untuk diperiksa. - Baris 41-42 (
test_hapus_default_menghapus_permanen): pengujian perilaku bawaan tanpaconfig.json— berkas lenyap dan folder sampah tidak pernah dibuat. - Baris 47-48 (
test_hapus_dengan_sampah): satu bedanya dari test sebelumnya hanyalahconfig.jsonyang ditulis ke dalam sandbox; sisa pernyataannya mengunci cabang sampah padaperintah_hapus(). - Baris 67-71 (
pytest.raises(SystemExit)):argparsemengakhiri program lewat exceptionSystemExit, bukan nilai balik — pytest menangkap exception tersebut dengan konteks managerpytest.raises()lalu memeriksacode-nya.
Menjalankan pytest #
Seluruh berkas dijalankan dengan satu perintah dari folder proyek:
python3 -m pytest test_main.py -vOutput yang Dihasilkan:
============================= 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:
[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"]- Baris 1-3 (
[build-system]): build system dipisahkan dari kode —pipmembuat lingkungan build sendiri lalu memanggilsetuptoolsuntuk membangun package. - Baris 5-9 (
[project]): metadata package;requires-python = ">=3.10"mencatat bahwa kita memakai union type sepertidict | Nonepada anotasi fungsi — sintaks PEP 604 yang hadir sejak Python 3.10. - Baris 11-12 (
[project.scripts]): inti distribusi CLI — pasanganfiletool = "main:main"berarti “buat perintahfiletoolyang menjalankan fungsimain()di dalam modulemain”. - Baris 14-15 (
py-modules = ["main"]): proyek ini berupa satu berkas, bukan folder package; deklarasipy-modulesmemberi tahusetuptoolsbahwa module tingkat atas bernamamainharus 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():
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 1Pemanggilan 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:
python3 -m venv .venv-test
.venv-test/bin/pip install -e .Output yang Dihasilkan (baris progres dipendekkan):
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.0Flag -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:
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:
#!/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:
.venv-test/bin/filetool --versi
.venv-test/bin/filetool daftar files | head -3
.venv-test/bin/filetool ukuran filesOutput yang Dihasilkan:
filetool 1.0.0.git/
__pycache__/
arsip-lama.ziptotal : 1.468.086 byte (1,4 MB)
berkas : 10
direktori: 2Pemanggilan 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"""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[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"""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