Membaca isi disk itu aman, tetapi sebuah pengelola file baru berguna begitu program sanggup mengubah disk: menyalin, memindahkan, bahkan menghapus. Setiap operasi tulis membawa risiko menimpa atau membuang berkas yang salah, jadi pada bagian ini kita membangun lima subperintah baru sekaligus pengamannya: salin dan pindahkan menolak menimpa, hapus menuntut konfirmasi eksplisit, dan organisasi selalu bisa dipratinjau lewat --dry-run. Seluruh jalur tetap ditangani pathlib; perpindahan data yang membutuhkan metadata dan lintas perangkat diserahkan pada module shutil.

salin dan pindahkan #

Kedua perintah mengikuti aturan cp dan mv yang sudah dikenal pengguna: tujuan berupa direktori berarti berkas masuk ke dalamnya dengan nama yang sama, tujuan berupa nama baru berarti berkas diubah namanya. Pada kedua kasus, filetool menolak menimpa berkas yang sudah ada.

python
def perintah_salin(args: argparse.Namespace) -> int:
    sumber = Path(args.sumber)
    tujuan = Path(args.tujuan)
    if not sumber.is_file():
        print(f"filetool: sumber bukan berkas: {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) -> int:
    sumber = Path(args.sumber)
    tujuan = Path(args.tujuan)
    if not sumber.exists():
        print(f"filetool: sumber tidak ditemukan: {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.move(sumber, tujuan)
    print(f"dipindahkan: {sumber} -> {tujuan}")
    return 0

Mari kita bedah baris-baris pentingnya:

  1. Baris 4 (if not sumber.is_file():): salin membatasi sumber pada berkas; menyalin direktori secara rekursif adalah pekerjaan yang lebih berisiko dan sengaja tidak dibuka di proyek ini.
  2. Baris 7-8 (if tujuan.is_dir():): ketika tujuan adalah direktori, operator / milik Path merangkai tujuan / sumber.name menjadi jalur akhir — nama berkas tetap dipertahankan.
  3. Baris 9-11 (if tujuan.exists():): pemeriksaan terakhir sebelum menulis; keberadaan tujuan membuat operasi dibatalkan dengan exit code 1 alih-alih menimpa diam-diam.
  4. Baris 12 (shutil.copy2(sumber, tujuan)): fungsi copy2() menyalin isi berkas sekaligus stempel waktunya — perilaku yang sama dengan cp -p.
  5. Baris 28 (shutil.move(sumber, tujuan)): fungsi move() menangani dua skenario sekaligus: pemindahan dalam satu perangkat (cukup mengganti nama) dan pemindahan lintas perangkat (menyalin lalu menghapus sumber).

Tiga percobaan berikut dijalankan berurutan pada pohon latihan Bagian 1:

bash
python3 main.py salin files/laporan.txt files/dokumen/laporan.txt
python3 main.py salin files/catatan.txt files/dokumen/
python3 main.py salin files/laporan.txt files/dokumen/laporan.txt

Output yang Dihasilkan:

text
tersalin: files/laporan.txt -> files/dokumen/laporan.txt
tersalin: files/catatan.txt -> files/dokumen/catatan.txt
filetool: tujuan sudah ada: files/dokumen/laporan.txt

Pemanggilan ketiga ditolak dan berakhir dengan exit code 1; salinan pertama tetap utuh. Perintah pindahkan berperilaku serupa:

bash
python3 main.py pindahkan files/catatan~ files/dokumen/catatan-cadangan

Output yang Dihasilkan:

text
dipindahkan: files/catatan~ -> files/dokumen/catatan-cadangan

hapus dengan Konfirmasi –ya #

Menghapus adalah satu-satunya operasi yang tidak bisa dibatalkan, sehingga filetool menuntut konfirmasi dua langkah: pengguna harus membubuhkan --ya secara eksplisit, tanpa itu perintah hanya melaporkan penolakannya.

python
def perintah_hapus(args: argparse.Namespace) -> 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
    target.unlink()
    print(f"terhapus: {target}")
    return 0
  1. Baris 6-9 (if not args.ya:): opsi --ya didaftarkan sebagai action="store_true", sehingga nilainya False ketika tidak dipakai; penolakan dicetak ke stdout karena pesan ini adalah hasil normal perintah, bukan galat program.
  2. Baris 10 (target.unlink()): method unlink() milik Path menghapus berkas; untuk sekarang penghapusan bersifat permanen, dan Bagian 3 mengalihkannya ke folder sampah lewat konfigurasi.
bash
python3 main.py hapus files/draf-sementara.tmp
python3 main.py hapus files/draf-sementara.tmp --ya

Output yang Dihasilkan:

text
menolak menghapus tanpa --ya: files/draf-sementara.tmp
jalankan ulang dengan --ya untuk benar-benar menghapus.
terhapus: files/draf-sementara.tmp

ukuran dan Jejak Sampah #

Perintah ukuran berperilaku seperti du -- summarize: menelusuri seluruh pohon secara rekursif lalu melaporkan total, jumlah berkas, dan jumlah direktori.

python
def perintah_ukuran(args: argparse.Namespace) -> int:
    akar = Path(args.path)
    if not akar.is_dir():
        print(f"filetool: bukan direktori: {akar}", file=sys.stderr)
        return 1
    total = 0
    jumlah_berkas = 0
    jumlah_direktori = 0
    for jalur in akar.rglob("*"):
        if jalur.is_dir():
            jumlah_direktori += 1
        else:
            total += jalur.stat().st_size
            jumlah_berkas += 1
    mentah = f"{total:,}".replace(",", ".")
    print(f"{'total':<9}: {mentah} byte ({format_size(total)})")
    print(f"{'berkas':<9}: {jumlah_berkas}")
    print(f"{'direktori':<9}: {jumlah_direktori}")
    return 0
  1. Baris 9 (for jalur in akar.rglob("*")): pola "*" membuat rglob() menghasilkan seluruh entri pada semua tingkat — berkas dan direktori sekaligus.
  2. Baris 13 (total += jalur.stat().st_size): hanya berkas yang dijumlahkan ukurannya; direktori diberi penghitung sendiri.
  3. Baris 15 (f"{total:,}".replace(",", ".")): penanda ribuan , dari format spec , diubah menjadi . agar konsisten dengan konvensi Indonesia.
bash
python3 main.py ukuran files

Output yang Dihasilkan:

text
total    : 1.470.202 byte (1,4 MB)
berkas   : 16
direktori: 4

Angka tersebut memuat dua salinan baru dari percobaan salin tadi. Namun coba perhatikan hasil cari berikut — keluarannya memuat berkas yang sama sekali bukan milik kita:

bash
python3 main.py cari files --pola "*"

Output yang Dihasilkan:

text
.git/HEAD
.git/config
__pycache__/modul.pyc
arsip-lama.zip
catatan.txt
dokumen/catatan-cadangan
dokumen/catatan.txt
dokumen/kontrak.txt
dokumen/laporan.txt
dokumen/proposal.txt
foto/gunung.png
foto/pantai.jpg
foto-liburan.jpg
konfig-lama.json
laporan.txt
skrip-tes.py

Folder .git dan __pycache__ ikut ditelusuri padahal keduanya hanyalah metadata alat. Masalah inilah yang diselesaikan konfigurasi eksklusi pada Bagian 3.

organisasi dengan dry-run #

Perintah terakhir sekaligus paling “menulis”: memindahkan setiap berkas lepas di root folder ke subfolder sesuai ekstensinya. Karena salah rencana berarti berkas tercecer, pratinjau menjadi warga kelas satu — opsi --dry-run mencetak rencana lengkap tanpa menyentuh disk.

python
def susun_rencana(akar: Path) -> list[tuple[Path, Path]]:
    """Rancang pemindahan setiap berkas ke folder sesuai ekstensinya."""
    rencana = []
    for berkas in sorted(akar.iterdir()):
        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) -> int:
    akar = Path(args.path)
    if not akar.is_dir():
        print(f"filetool: bukan direktori: {akar}", file=sys.stderr)
        return 1
    rencana = susun_rencana(akar)
    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
  1. Baris 1 (def susun_rencana(...)): perancanaan dipisah dari eksekusi — fungsi ini murni menghitung pasangan (sumber, tujuan) tanpa efek samping apa pun, sehingga mudah diuji pada Bagian 4.
  2. Baris 7 (folder = berkas.suffix.lstrip(".") or "tanpa-ekstensi"): ekstensi .txt menjadi nama folder txt; berkas tanpa ekstensi berlabuh di folder tanpa-ekstensi.
  3. Baris 18-20 (if not rencana:): folder yang sudah rapi tidak diproses ulang — perintah ini aman dijalankan berulang (idempotent).
  4. Baris 23-25 (if args.dry_run:): mode pratinjau mencetak rencana lalu berhenti sebelum loop pemindahan dieksekusi.
  5. Baris 26-28 (tujuan.parent.mkdir(parents=True, exist_ok=True)): subfolder tujuan dibuat hanya saat benar-benar diperlukan; exist_ok=True mencegah galat ketika folder sudah ada dari eksekusi sebelumnya.
bash
python3 main.py organisasi files --dry-run

Output yang Dihasilkan:

text
files/arsip-lama.zip -> files/zip/arsip-lama.zip
files/catatan.txt -> files/txt/catatan.txt
files/foto-liburan.jpg -> files/jpg/foto-liburan.jpg
files/konfig-lama.json -> files/json/konfig-lama.json
files/laporan.txt -> files/txt/laporan.txt
files/skrip-tes.py -> files/py/skrip-tes.py
(dry-run: 6 berkas akan dipindahkan)

Rencana tersebut terlihat wajar, sehingga kita jalankan sungguhan:

bash
python3 main.py organisasi files
python3 main.py daftar files

Output yang Dihasilkan:

text
files/arsip-lama.zip -> files/zip/arsip-lama.zip
files/catatan.txt -> files/txt/catatan.txt
files/foto-liburan.jpg -> files/jpg/foto-liburan.jpg
files/konfig-lama.json -> files/json/konfig-lama.json
files/laporan.txt -> files/txt/laporan.txt
files/skrip-tes.py -> files/py/skrip-tes.py
6 berkas dipindahkan.
text
.git/
__pycache__/
dokumen/
foto/
jpg/
json/
py/
txt/
zip/

Pemanggilan organisasi untuk kedua kalinya kini hanya menjawab tidak ada berkas yang perlu diorganisasi. dengan exit code 0.

Mendaftarkan Lima Subperintah Baru #

Seluruh fungsi di atas harus terdaftar pada buat_parser(). Lima blok berikut ditambahkan setelah pendaftaran cari, sebelum return parser — potongan lengkapnya terlihat pada berkas main.py di bagian Berkas Sumber:

python
    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.set_defaults(func=perintah_organisasi)

Nilai VERSI juga dinaikkan menjadi "2.0.0" untuk menandai bertambahnya kemampuan menulis, dan bantuan utama kini memperlihatkan kedelapan subperintah — dokumentasi program tumbuh bersama kodenya tanpa kita menulisnya dua kali.

filetool kini bisa membaca dan menulis, lengkap dengan pengaman konfirmasi serta pratinjau. Namun perilakunya masih seragam untuk semua orang dan semua folder: sampah ikut dihitung, penghapusan selalu permanen, dan galat operasi baru ditangani satu per satu. Bagian 3 menuntaskan ketiganya lewat berkas konfigurasi dan penanganan galat terpusat.

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 shutil
import sys
from datetime import datetime
from pathlib import Path

VERSI = "2.0.0"


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 perintah_daftar(args: argparse.Namespace) -> int:
    direktori = Path(args.path)
    if not direktori.is_dir():
        print(f"filetool: bukan direktori: {direktori}", file=sys.stderr)
        return 1
    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) -> int:
    berkas = Path(args.path)
    if not berkas.is_file():
        print(f"filetool: bukan berkas: {berkas}", file=sys.stderr)
        return 1
    stat = berkas.stat()
    for label, nilai in [
        ("jalur", str(berkas)),
        ("jenis", "berkas"),
        ("ekstensi", berkas.suffix or "(tidak ada)"),
        ("ukuran", f"{stat.st_size} byte ({format_size(stat.st_size)})"),
        ("diubah", format_waktu(stat.st_mtime)),
    ]:
        print(f"{label:<9}: {nilai}")
    return 0


def perintah_cari(args: argparse.Namespace) -> int:
    akar = Path(args.path)
    if not akar.is_dir():
        print(f"filetool: bukan direktori: {akar}", file=sys.stderr)
        return 1
    hasil = sorted(p for p in akar.rglob(args.pola) if p.is_file())
    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) -> int:
    sumber = Path(args.sumber)
    tujuan = Path(args.tujuan)
    if not sumber.is_file():
        print(f"filetool: sumber bukan berkas: {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) -> int:
    sumber = Path(args.sumber)
    tujuan = Path(args.tujuan)
    if not sumber.exists():
        print(f"filetool: sumber tidak ditemukan: {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.move(sumber, tujuan)
    print(f"dipindahkan: {sumber} -> {tujuan}")
    return 0


def perintah_hapus(args: argparse.Namespace) -> 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
    target.unlink()
    print(f"terhapus: {target}")
    return 0


def perintah_ukuran(args: argparse.Namespace) -> int:
    akar = Path(args.path)
    if not akar.is_dir():
        print(f"filetool: bukan direktori: {akar}", file=sys.stderr)
        return 1
    total = 0
    jumlah_berkas = 0
    jumlah_direktori = 0
    for jalur in akar.rglob("*"):
        if jalur.is_dir():
            jumlah_direktori += 1
        else:
            total += jalur.stat().st_size
            jumlah_berkas += 1
    mentah = f"{total:,}".replace(",", ".")
    print(f"{'total':<9}: {mentah} byte ({format_size(total)})")
    print(f"{'berkas':<9}: {jumlah_berkas}")
    print(f"{'direktori':<9}: {jumlah_direktori}")
    return 0


def susun_rencana(akar: Path) -> list[tuple[Path, Path]]:
    """Rancang pemindahan setiap berkas ke folder sesuai ekstensinya."""
    rencana = []
    for berkas in sorted(akar.iterdir()):
        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) -> int:
    akar = Path(args.path)
    if not akar.is_dir():
        print(f"filetool: bukan direktori: {akar}", file=sys.stderr)
        return 1
    rencana = susun_rencana(akar)
    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}")
    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 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.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.set_defaults(func=perintah_organisasi)

    return parser


def main(argv: list[str] | None = None) -> int:
    args = buat_parser().parse_args(argv)
    return args.func(args)


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