Skip to main content
Learning Center
Workflow Automation

Organize Files

Reading a Folder Without Being Fooled By It

Surveying a directory the way an unattended job has to - separating files from folders, normalising suffixes, and producing the same answer in the same order every time.

Lesson 4 of 18 in the recommended order · About 25 min (estimate)

On this page
Workflow automation glossary — terms and common confusions
Dry run
A run that reports every change it would make and makes none of them.

A dry run that still writes a log file, sends a message, or creates a folder is not a dry run.

Idempotent
Running it again leaves the same result as running it once.

"It did not crash the second time" is not the same property; check what the second run changed.

Run record
The stored facts about one execution: when it started, what it read, what it changed, how it ended.

A run record is not the log. The log is prose for a person; the record is data the next run reads.

Transient failure
A failure that a later identical attempt could succeed at, such as a timeout.

A rejected record and a dropped connection are not the same failure, and retrying the first one forever is a bug.

Backoff
Waiting longer between successive retries instead of retrying immediately.

Backoff without a maximum attempt count is an unbounded loop with a politeness delay.

Request budget
A hard cap on how many requests one run may make, checked before each request.

A page limit is not a budget if a retry can make extra requests the limit never counts.

Quarantine
Setting aside a record a run could not process, with the reason, so the run continues.

Quarantine is not "skip". A skipped record leaves no trace; a quarantined one is countable and reviewable.

Reconciliation
Showing that the counts in a report add up: read equals processed plus quarantined plus rejected.

A report whose totals cannot be reconciled is a summary of what the code believed, not of what happened.

Trigger
The event or time that causes a run to start.

Scheduling a trigger is a decision made on a machine, not something a browser lesson can install for you.

Heartbeat
A signal a healthy run emits, whose absence is itself the alert.

Alerting only on errors cannot detect the job that stopped running at all, which is the most common outage.

Staleness
How old the most recent successful run is, compared with how old it is allowed to be.

A green last run is not freshness. Ask when it ran, not whether it passed.

Atomic replace
Writing output to a temporary name and renaming it into place in one step.

A reader never sees a half-written file; an interrupted run leaves the previous output intact.

Transport
The injected callable that actually performs a request, separate from the client that interprets it.

A client that builds its own connection cannot be tested without a network, which is why the seam exists.

In-memory filesystem
The filesystem this course’s browser exercises operate on, which lives only inside the tab.

It behaves like a filesystem and is not your disk: nothing an exercise writes exists after the run ends.

Outcome

By the end of this lesson you can point a Python program at a folder and get back an accurate, repeatable description of what is in it: which entries are files and which are folders, what kind each file is, and in an order that does not change between runs.

Why it matters

Every automation in this course starts by looking at something. For most of them, that something is a folder, and a folder is a surprisingly unreliable narrator.

A directory listing arrives in whatever order the filesystem felt like. It contains folders alongside files. It contains Report.CSV and report.csv, which are the same name on Windows and two different names on Linux. It contains notes.tar.gz, whose "extension" is either .gz or .tar.gz depending on what you meant. It contains README, which has no extension at all and will break any code that assumes one.

None of that is exotic. All of it is in the first real folder you point a script at, and each one produces a different flavour of the same bug: a job that processed 41 of 42 files and reported success.

Getting the survey right is also what makes everything after it reviewable. If the listing is deterministic, you can diff today's run against yesterday's and the difference is real. If it is not, every run looks different and you cannot tell a change from a shuffle.

Concept

pathlib.Path is the standard-library type for filesystem paths. A Path is not a string, and the difference is the point: it knows how to join, split, and inspect a path without any string surgery.

folder / "inbox" / "report.csv"   join, correct separator on any platform
path.name                          "report.csv"
path.stem                          "report"
path.suffix                        ".csv"      (the last one only)
path.suffixes                      [".tar", ".gz"] for notes.tar.gz
path.parent                        the containing folder
path.is_file() / path.is_dir()     what kind of thing this is
path.iterdir()                     the entries directly inside a folder
path.rglob("*.csv")                matching paths at any depth; filter file/link types

Four rules turn that into a survey you can trust.

1. Ask what each entry is; do not assume. iterdir() yields folders as well as files. A job that copies "every entry" into a processing loop will eventually try to read a directory as text and produce an IsADirectoryError — or worse, silently skip it inside a broad except.

2. Normalise the suffix before you compare it. path.suffix preserves case, so .CSV and .csv are different strings. Lowercase it once, at the point of comparison, and the rest of the program stops caring. path.suffix is also '' for a name with no dot, which is a real case — decide what it means rather than letting it fall through a chain of elifs into nothing.

3. suffix is the last extension only. For notes.tar.gz, suffix is .gz and suffixes is ['.tar', '.gz']. Neither is wrong; they answer different questions. Choose deliberately.

4. Sort. Every time. iterdir() makes no ordering promise, and the order it happens to give you today may change after a file is deleted and rewritten. Sorting is one call and makes repeated runs over the same folder comparable. Path ordering differs between Windows and POSIX when names mix case, so choose an explicit sort key if output must match across platforms.

There is one more habit worth building now, before the next lesson makes it matter: decide what to do with each entry before doing anything to any of them. A survey is a read-only pass, and the plan it produces is the artefact the previous module taught you to review.

Read the code

A practice folder, built and then surveyed:

import pathlib
import tempfile

root = pathlib.Path(tempfile.mkdtemp()) / "downloads"
root.mkdir(parents=True)
(root / "keep").mkdir()
for name in ["march.csv", "APRIL.CSV", "notes.tar.gz", "LICENCE"]:
    (root / name).write_text("placeholder", encoding="utf-8")


def survey(folder):
    """Read-only description of a folder: its files by kind, and its subfolders."""
    files, folders = [], []
    for entry in sorted(folder.iterdir()):
        if entry.is_dir():
            folders.append(entry.name)
        else:
            files.append((entry.name, entry.suffix.lower() or "(none)"))
    return files, folders


files, folders = survey(root)
print("files:", len(files))
print("folders:", len(folders))
for name, kind in files:
    print(f"  {name} -> {kind}")

sorted(folder.iterdir()) sorts Path objects, so a second run over this unchanged folder on the same platform has a stable order. Windows path comparisons fold case; POSIX path comparisons do not. Choose an explicit name key if the order must match across platforms.

entry.suffix.lower() or "(none)" does two jobs in one expression. The .lower() makes .CSV and .csv the same kind. The or "(none)" catches the empty string a name with no dot produces, turning a value that would silently group with nothing into one that appears in the output where somebody can see it.

is_dir() is asked first, because the interesting question about keep is not what its extension is. This controlled practice folder contains only ordinary files and one ordinary directory, so the else branch means file here. For a real folder, decide how to handle links before is_dir() or is_file() follows their targets, and report anything unclassified.

Predict the output

Predict every line, including the order of the indented ones.

Check your prediction
files: 4
folders: 1
  APRIL.CSV -> .csv
  LICENCE -> (none)
  march.csv -> .csv
  notes.tar.gz -> .gz

The order comes from sorting the Path values. For these four filenames, Windows and POSIX both put APRIL.CSV and LICENCE before march.csv; that agreement is specific to this sample. Windows paths compare case-insensitively while POSIX paths do not. If results must sort the same way across platforms, choose an explicit key such as key=lambda p: (p.name.casefold(), p.name).

notes.tar.gz reports .gz, because suffix is the last extension only.

The worked folder has five direct entries

downloads/ — one-level tree

├─ APRIL.CSV
Direct file; .CSV becomes .csv for comparison.
├─ LICENCE
Direct file; empty suffix is reported as (none).
├─ keep/
Direct directory; empty in this worked sample.
├─ march.csv
Direct file; suffix .csv.
└─ notes.tar.gz
Direct file; suffix .gz, suffixes .tar and .gz.

The worked program lists four file rows and counts keep/ as one folder. All five names are direct children of downloads/.

Where the survey stops

downloads/
The survey root.
iterdir()
Returns the five direct entries, including keep/; it does not enter keep/.
rglob("*.csv")
A separate recursive path search. Filter matches by file and link policy before treating them as files.

The worked keep/ folder is empty, so this figure adds no hypothetical descendant. The later exercise has a different archive/ folder with a nested file.

Exact worked-folder names and classifications. The branch marks show one parent and five direct children; the text names the same relationships without relying on the marks or color. Suffix and traversal labels apply to this controlled ordinary-file sample. A real-folder survey needs a deliberate symlink policy.

Modify the code

Replace entry.is_dir() with entry.suffix == "" as the test for "this is a folder".

What changes, and why

keep is still classified as a folder, and now LICENCE is too:

files: 3
folders: 2
  APRIL.CSV -> .csv
  march.csv -> .csv
  notes.tar.gz -> .gz

LICENCE has vanished from the file listing entirely, which is the part worth noticing: it was not reported as a problem, it was reported as a folder.

The program is guessing at a fact it could have asked for. is_dir() asks the filesystem what the entry actually is; suffix == "" asks what its name looks like, and a name is not a claim about anything. Folders can have dots in them (v1.2, 2026.archive) and files can have no extension at all — LICENCE, Makefile, Dockerfile, and most of the interesting files in a repository.

The general form is worth keeping: when the filesystem can answer a question directly, ask it. Inferring from the name is how a job ends up treating a folder called data.csv as a spreadsheet.

Debug the bug

An assistant was asked to "count the spreadsheets in the downloads folder". It produced this.

def count_spreadsheets(folder):
    total = 0
    for entry in folder.iterdir():
        if str(entry).endswith(".csv"):
            total += 1
    return total
What's actually wrong

Run it against the practice folder above and it returns 1. There are two.

Three defects and one unnecessary path-string conversion. The case check makes this sample count wrong:

  1. It compares with case. APRIL.CSV does not end with .csv, so it is not counted. On the machine where the assistant's example was tested, every file happened to be lowercase.
  2. It converts a Path to text unnecessarily. Here str(entry).endswith(".csv") still tests the final child name; a parent called exports.csv cannot make another child match. entry.suffix expresses the intended final extension directly and avoids reasoning about a full path string.
  3. It never checks is_file(). A folder named archive.csv is counted as a spreadsheet.
  4. It returns a bare number. When the count is wrong there is nothing to look at. A survey that returns the names costs nothing extra and is the difference between "41" and "here are the 41, and the one that was skipped".

The version that survives a real folder:

def find_spreadsheets(folder):
    """Every .csv file directly inside folder, by name, in a stable order."""
    return sorted(
        entry.name for entry in folder.iterdir() if entry.is_file() and entry.suffix.lower() == ".csv"
    )

It asks the filesystem what each entry is, compares the suffix rather than the path, normalises case once, and returns the names so the answer can be checked instead of trusted.

Try it yourself

The starter builds a practice folder in the browser's in-memory filesystem from the supplied FILES_JSON, and creates one subfolder inside it. Write survey, which returns the file count, the folder count, and a count of files by normalised suffix.

Loading this exercise…

Practical challenge (optional)

Optional, and the transfer task for this lesson: survey a folder you did not design.

Extend survey to also report the largest file by entry.stat().st_size, and the number of entries it could not classify. Then run it against a folder with something awkward in it — a name with a trailing space, a file with two dots, an empty subfolder, a shortcut or symbolic link.

What a good answer looks like

The interesting result can be the "could not classify" count, because real folders may contain entries that are neither ordinary files nor ordinary directories. Decide on links explicitly: test is_symlink() before is_file() or is_dir() if you do not want to follow targets. Those latter checks follow valid links; a dangling link answers False to both. Report links separately or mark them unclassified rather than silently counting them as ordinary files.

A survey that reports that entry as unclassified is telling you the truth. One that silently drops it is where "processed 41 of 42 files" comes from.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Why must a survey sort the entries it lists?
  2. What does path.suffix return for notes.tar.gz, and what does path.suffixes return?
  3. Why is entry.suffix == "" a poor test for whether something is a folder?
  4. A job reports "processed 41 files" over a folder containing 42. Name two things in the folder that could explain it.
Answers
  1. iterdir() promises no order, so an unsorted listing can differ between runs. Sorting makes runs on the same platform comparable. If output must match across platforms, use an explicit key because Windows and POSIX Path order names with mixed case differently.
  2. suffix is .gz, the last extension only. suffixes is ['.tar', '.gz']. They answer different questions and neither is the default right answer.
  3. It infers a fact from a name. Files with no extension are common — LICENCE, Makefile — and folders with dots in them exist too. is_dir() asks the filesystem instead of guessing.
  4. Any two of: a file whose extension was uppercase and did not match a case-sensitive comparison; a subdirectory counted or skipped as though it were a file; a file with no extension that fell through a chain of extension tests; an entry that is neither a plain file nor a directory, such as a link to something deleted.

Sign in to track your progress on this exercise.

Summary and next step

Use pathlib, ask the filesystem what each entry is rather than inferring it from the name, set a policy for links, lowercase a suffix before comparing it, decide what an empty suffix means, and sort so that repeated runs over the same folder produce the same answer. That survey is a read-only pass, which is exactly what the next lesson needs: it turns a survey into a plan of renames, and checks that no two of them are aiming at the same name before anything moves.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.