Bagian 2 ditutup dengan masalah yang khas untuk alat file: folder .git dan __pycache__ ikut ditelusuri, penghapusan selalu permanen, dan setiap perintah menangani galatnya sendiri-sendiri dengan blok if yang berulang. Pada bagian ini kita menuntaskan ketiganya. filetool memperoleh berkas konfigurasi JSON yang bisa menyesuaikan perilaku per proyek, environment variable FILETOOL_CONFIG untuk mengarahkan berkas tersebut, serta satu titik penanganan galat terpusat yang mengubah FileNotFoundError dan teman-temannya menjadi pesan yang ramah.
Berkas Konfigurasi #
Empat kunci berikut mengendalikan perilaku filetool:
{
"pola_eksklusi": [".git", "__pycache__", "*.tmp", "*~", ".filetool-sampah"],
"kedalaman_rekursif": 3,
"gunakan_sampah": true,
"folder_sampah": ".filetool-sampah"
}| Kunci | Tipe | Nilai bawaan | Efek |
|---|---|---|---|
pola_eksklusi | list | [".git", "__pycache__", "*.tmp", "*~"] | nama yang cocok pola fnmatch dilewati oleh cari, ukuran, dan organisasi |
kedalaman_rekursif | int | 5 | jumlah tingkat di bawah direktori awal yang boleh ditelusuri |
gunakan_sampah | bool | false | hapus memindahkan berkas ke folder sampah, bukan menghapus permanen |
folder_sampah | str | ".filetool-sampah" | nama folder sampah, dibuat di direktori kerja saat perintah berjalan |
Simpan JSON di atas sebagai config.json di direktori kerja. Pola ".filetool-sampah" sengaja dimasukkan ke daftar eksklusi agar folder sampah sendiri tidak ikut terhitung pada penelusuran berikutnya.
Memuat Konfigurasi Berlapis #
Konfigurasi dimuat dalam tiga lapis: nilai bawaan di kode selalu tersedia, berkas config.json di direktori kerja menimpanya bila ada, dan environment variable FILETOOL_CONFIG mengalihkan lokasi berkas untuk skenario seperti satu berkas konfigurasi dipakai banyak proyek.
KONFIG_BAWAAN = {
"pola_eksklusi": [".git", "__pycache__", "*.tmp", "*~"],
"kedalaman_rekursif": 5,
"gunakan_sampah": False,
"folder_sampah": ".filetool-sampah",
}
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 configMari kita bedah baris-baris pentingnya:
- Baris 1 (
KONFIG_BAWAAN = {): nilai bawaan hidup di kode, bukan di berkas — pengguna baru mendapatkan program yang berjalan tanpa menulis konfigurasi apa pun. - Baris 11 (
config = dict(KONFIG_BAWAAN)):dict(...)membuat salinan; tanpa salinan,config.update()pada baris 33 akan mengotori dictionary bawaan itu sendiri. - Baris 12 (
os.environ.get("FILETOOL_CONFIG", "config.json")): fungsiget()mengembalikan nilai environment variable bila diisi, jika tidak diisi dikembalikan"config.json"sebagai jalur baku. - Baris 14-19 (
if not berkas.exists():): berkas baku yang tidak ada adalah hal normal, jadi program lanjut dengan nilai bawaan; tetapi berkas yang ditunjukFILETOOL_CONFIGadalah permintaan eksplisit pengguna, sehingga hilangnya berkas itu adalah galat yang layak menghentikan program dengan exit code1. - Baris 20-29 (
try:dan duaexceptpertama): modulejsonmem-parsing isi berkas menjadi structure Python;JSONDecodeErrorditangkap terpisah agar pesan galatnya bisa menyebut posisi baris dan kolom persis lokasi kesalahan. - Baris 30-32 (
if not isinstance(data, dict):): JSON yang sah bisa berupa list atau string; hanya objek (dictionary Python) yang masuk akal sebagai konfigurasi, sisanya ditolak sebelum sempat mengacaukanconfig.update().
Penyaringan Berkas #
Pola eksklusi dibandingkan terhadap nama berkas dengan fungsi fnmatch() dari standard library. Tiga fungsi kecil membagi pekerjaan ini agar bisa diuji terpisah pada Bagian 4:
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)]- Baris 1-6 (
parse_exclude): menerima bentuk apa pun yang wajar — list dariconfig.json, string terpisah koma dari opsi--kecuali, atauNone— lalu mengembalikan list pola yang bersih dari spasi dan elemen kosong. - Baris 9-11 (
cocok_pola): satu nama dinyatakan cocok bila cocok dengan salah satu pola;fnmatch(".git", ".git")bernilaiTruekarena string identik juga pola yang sah. - Baris 14-16 (
filter_files): list comprehension menyaring daftarPathdan mengembalikan hanya yang lolos — fungsi murni tanpa efek samping, pasangannya diuji langsung pada Bagian 4.
Penelusuran rekursif sekarang ditangani fungsi telusuri(), pengganti rglob() polos dari Bagian 2, karena penelusuran ini harus menghormati dua aturan konfigurasi sekaligus: melewati pola eksklusi dan berhenti pada batas kedalaman.
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- Baris 8 (
def jalan(...)): fungsi bersarangjalan()memanggil dirinya sendiri untuk setiap subdirektori — direktori yang cocok pola eksklusi tidak pernah dimasuki, sehingga seluruh isinya otomatis terlewati. - Baris 9-11 (
if tingkat > kedalaman:): parametertingkatnaik satu setiap turun satu direktori; begitu melewatikedalaman_rekursif, cabang tersebut dipangkas. - Baris 13-15 (
if cocok_pola(entri.name, pola):): pemeriksaan dilakukan pada nama, bukan jalur penuh, sehingga pola"*.tmp"berlaku untuk berkas sementara di tingkat mana pun.
Perintah cari, ukuran, dan organisasi memakai hasil telusuri(). Sebagai contoh, cari kini berbunyi:
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- Baris 3 (
pola = ... + parse_exclude(args.kecuali)): pola dari berkas konfigurasi digabung dengan opsi--kecualidari command line — pola sekali pakai tidak perlu mengubahconfig.json. - Baris 4 (
{**config, "pola_eksklusi": pola}): unpacking dictionary membuat salinan config dengan kuncipola_eksklusiyang sudah digabung, tanpa mengubah dictionary aslinya.
Dengan config.json di direktori kerja, perintah yang di Bagian 2 memuntahkan sampah kini bersih:
python3 main.py cari files --pola "*"
python3 main.py ukuran filesOutput yang Dihasilkan:
arsip-lama.zip
catatan.txt
dokumen/kontrak.txt
dokumen/proposal.txt
foto/gunung.png
foto/pantai.jpg
foto-liburan.jpg
konfig-lama.json
laporan.txt
skrip-tes.pytotal : 1.468.086 byte (1,4 MB)
berkas : 10
direktori: 2Bandingkan dengan Bagian 2: draf-sementara.tmp, catatan~, .git, dan __pycache__ tidak lagi dihitung. Opsi --kecuali bekerja untuk pengecualian sekali jalan:
python3 main.py cari files --pola "*" --kecuali "*.jpg, *.png"Output yang Dihasilkan:
arsip-lama.zip
catatan.txt
dokumen/kontrak.txt
dokumen/proposal.txt
konfig-lama.json
laporan.txt
skrip-tes.pyUntuk mencoba batas kedalaman, kita siapkan berkas config-dangkal.json yang isinya hanya {"kedalaman_rekursif": 1} lalu mengarahkannya lewat environment variable — berkas konfigurasi baku di direktori kerja tidak tersentuh:
printf '{\n "kedalaman_rekursif": 1\n}\n' > config-dangkal.json
FILETOOL_CONFIG=config-dangkal.json python3 main.py ukuran filesOutput yang Dihasilkan:
total : 257.398 byte (251,4 KB)
berkas : 6
direktori: 2Hanya berkas pada tingkat pertama yang dihitung; isi dokumen/ dan foto/ terpangkas. Sebaliknya, jika FILETOOL_CONFIG menunjuk berkas yang tidak ada, program berhenti dengan galat yang jelas dan exit code 1:
filetool: konfigurasi tidak ditemukan: tidak-ada.jsonhapus ke Folder Sampah #
Dengan gunakan_sampah: true pada config.json, cabang baru di perintah_hapus() mengalihkan penghapusan ke folder sampah. Nama folder diambil dari konfigurasi, dan berkas dengan nama yang sama di sampah membuat operasi dibatalkan:
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- Baris 10 (
if config.get("gunakan_sampah"):): methodget()mengembalikanNonesaat kunci tidak ada, danNonedianggap salah — pengguna tanpa berkas konfigurasi tetap mendapat perilaku bawaan (hapus permanen). - Baris 12 (
folder_sampah.mkdir(exist_ok=True)): folder sampah dibuat saat pertama kali dibutuhkan;exist_ok=Truemenjadikan panggilan berikutnya tidak error.
python3 main.py hapus files/konfig-lama.json --ya
python3 main.py daftar .filetool-sampahOutput yang Dihasilkan:
ke sampah: files/konfig-lama.json -> .filetool-sampah/konfig-lama.jsonkonfig-lama.jsonPenanganan Galat Terpusat #
Bagian 2 menempelkan blok if not direktori.is_dir(): di hampir setiap perintah. Bagian ini menghapus semuanya dan membiarkan exception bekerja: iterdir(), stat(), dan open() milik sistem operasi sudah memunculkan FileNotFoundError, NotADirectoryError, atau PermissionError pada kondisi yang tepat. Tugas kita tinggal menangkap ketiganya di satu tempat — fungsi 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 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- Baris 3-6 (
logging.basicConfig(...)): satu panggilan di awal mengatur format logfiletooluntuk seluruh program; pesanWARNINGke atas tampil secara baku, sedangkanDEBUGhanya muncul ketika pengguna menambahkan opsi global--verbosesebelum subperintah. - Baris 11 (
return args.func(args, config)): seluruh perintah kini menerima(args, config); inilah satu-satunya titik pemanggilan, sehingga satu bloktrymenjaga semua subperintah sekaligus. - Baris 12-20 (
except FileNotFoundError ...): attributee.filenameberisi jalur penyebab galat yang diisi sistem operasi; ekspresie.filename or eberjaga saat exception dibangkitkan tanpafilename.
Konsekuensi penghapusan blok pemeriksaan: perintah daftar menyusut menjadi tiga baris inti karena iterdir() sendiri yang memunculkan galat. Tiga transkrip berikut membuktikan pesan akhirnya tetap informatif:
python3 main.py info tidak-ada.txt
python3 main.py daftar files/laporan.txt
mkdir -p files/terkunci && echo rahasia > files/terkunci/rahasia.txt && chmod 000 files/terkunci
python3 main.py daftar files/terkunciOutput yang Dihasilkan:
filetool: tidak ditemukan: tidak-ada.txtfiletool: bukan direktori: files/laporan.txtfiletool: izin ditolak: files/terkunciKetiganya berakhir dengan exit code 1 dan pesan di stderr — skrip tetap bisa membedakan kegagalan lewat $?, manusia tetap tahu penyebabnya. Setelah mengembalikan izin folder dengan chmod 755 files/terkunci, isi folder kembali bisa dibaca.
Logging dengan –verbose #
Pesan logging.debug() yang ditanam di muat_konfig() dan telusuri() tidak pernah muncul pada pemakaian biasa. Dengan opsi --verbose, alasan setiap keputusan penelusuran terlihat:
python3 main.py --verbose cari files --pola "*.py"Output yang Dihasilkan:
[DEBUG] konfigurasi dimuat dari config.json
[DEBUG] lewati files/.git (cocok pola eksklusi)
[DEBUG] lewati files/__pycache__ (cocok pola eksklusi)
[DEBUG] lewati files/catatan~ (cocok pola eksklusi)
[DEBUG] lewati files/draf-sementara.tmp (cocok pola eksklusi)
skrip-tes.pyBaris [DEBUG] ditulis logging ke stderr sedangkan hasil pencarian ditulis print() ke stdout, sehingga keduanya bisa dipisahkan dengan pengalihan aliran bila perlu.
filetool kini berperilaku sesuai konteks: konfigurasi per proyek, penghapusan yang bisa dipulihkan, galat yang seragam, dan jejak keputusan yang bisa ditelusuri. Satu kelemahan masih tersisa: setiap perubahan kode sejauh ini kita yakiki dengan mata sendiri. Bagian 4 mengotomatiskannya dengan pytest, lalu memaketkan program agar terpasang sebagai perintah filetool yang sesungguhnya.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
config.json
Unduh berkas{
"pola_eksklusi": [".git", "__pycache__", "*.tmp", "*~", ".filetool-sampah"],
"kedalaman_rekursif": 3,
"gunakan_sampah": true,
"folder_sampah": ".filetool-sampah"
}
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 = "3.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 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())