Python Part II · Module 18 · VLSI Files, Paths and Project Structures
pathlib: Paths as Objects
Two chapters have now established that a path has structure and that treating it as text loses that structure in ways nothing detects. The standard library's answer is a type: an object that knows it is a path, exposes the parts a path actually has, and refuses the operations that only make sense for strings. You have already used a narrow slice of it in Modules 15 and 16; this chapter is the rest, and it is deliberately not a tour of every method — it is the dozen operations that cover almost all flow scripting, plus the one structural property that makes the whole thing worth adopting.
Foundation14 min readPythonPart IIPathspathlibVerification Flow
Part II · Module 18 · Chapter 18.3 · pathlib: Paths as Objects
1. What You Already Have
Chapters 15.8 and 15.9 used Path narrowly and deliberately: exists(), is_file(), is_dir(), stat().st_size, read_text(), write_text(), rename(), unlink() and touch(). Those were the methods those chapters' subjects needed, and everything else was deferred to here.
This chapter is the deferred part: building paths rather than asking about them. The canonical form:
log = Path(run_dir) / "logs" / f"{test}.log"
text = log.read_text()Two lines that do what Chapter 18.2 spent a chapter failing to do safely with strings.
2. Building a Path
from pathlib import Path
run_dir = "runs/seed_41"
test = "smoke"
log = Path(run_dir) / "logs" / f"{test}.log"
print("built :", log)
print("type :", type(log).__name__)
print("is a Path :", isinstance(log, Path))built : runs/seed_41/logs/smoke.log
type : PosixPath
is a Path : TrueThe / operator joins components, inserting exactly one separator. It reads like a path, which is the point — the code looks like the thing it builds.
PosixPath is the concrete class for this operating system; Path() picks it for you, which is Chapter 16.9's inheritance visible in the standard library.
Trailing slashes in the input cannot break it, which was Chapter 18.2's whole failure mode:
from pathlib import Path
for base in ["runs/seed_41", "runs/seed_41/"]:
built = Path(base) / "logs" / "sim.log"
print(f"{base!r:<18} -> {built}")
print("identical:", (Path("runs/seed_41") / "logs") == (Path("runs/seed_41/") / "logs"))'runs/seed_41' -> runs/seed_41/logs/sim.log
'runs/seed_41/' -> runs/seed_41/logs/sim.log
identical: TrueTwo spellings, one result, and they compare equal — which Chapter 18.2 showed the string versions do not.
3. The Absolute-Component Rule Still Applies
pathlib did not fix the behaviour from Chapter 18.2 Section 4, and assuming it did is a reasonable mistake worth pre-empting:
from pathlib import Path
print("A normal :", Path("runs") / "seed_41" / "sim.log")
print("B absolute :", Path("runs") / "/etc" / "passwd")
print("C inside :", (Path("runs") / "/scratch/x").is_relative_to("runs"))A normal : runs/seed_41/sim.log
B absolute : /etc/passwd
C inside : FalseAn absolute component still discards everything before it. The improvement is is_relative_to, which gives you a one-call containment check that the string version had to spell with startswith — and which Chapter 18.9 uses before deleting anything.
4. The Parts of a Path
This is where pathlib is genuinely better rather than merely tidier:
from pathlib import Path
p = Path("runs/seed_41/logs/smoke.log")
print("name :", p.name)
print("stem :", p.stem)
print("suffix :", p.suffix)
print("parent :", p.parent)
print("parts :", p.parts)
print("parents :", [str(x) for x in p.parents])name : smoke.log
stem : smoke
suffix : .log
parent : runs/seed_41/logs
parts : ('runs', 'seed_41', 'logs', 'smoke.log')
parents : ['runs/seed_41/logs', 'runs/seed_41', 'runs', '.']parts is a tuple, so a path becomes something you can test membership against — "generated" in p.parts is Chapter 18.6's filter, and it is awkward to write correctly with string operations because "generated" in str(p) also matches a file called regenerated.sv.
parents is a sequence, indexable from the immediate parent outwards. parents[1] is the grandparent, and that single expression is the basis of every project-root anchor in Chapter 18.10.
Changing a path's pieces returns a new path rather than mutating:
from pathlib import Path
log = Path("runs/seed_41/logs/smoke.log")
print("with_suffix :", log.with_suffix(".wlf"))
print("with_name :", log.with_name("cdc.log"))
print("with_stem :", log.with_stem("cdc"))
print("original :", log)with_suffix : runs/seed_41/logs/smoke.wlf
with_name : runs/seed_41/logs/cdc.log
with_stem : runs/seed_41/logs/cdc.log
original : runs/seed_41/logs/smoke.logThe last line is the one to read. After three calls that each produced a modified path, log itself is unchanged — these methods return a new Path and leave the receiver alone, exactly like the string methods of Chapter 4.6. There is no in-place form, which means a path cannot be modified out from under a caller that is holding it.
5. Reading and Writing
The whole-file shorthands from Chapter 15.9, with the forward pointer now redeemed:
from pathlib import Path
run = Path("runs/seed_41")
(run / "logs").mkdir(parents=True)
log = run / "logs" / "smoke.log"
log.write_text("UVM_INFO start\nUVM_ERROR drv.sv(88)\nSimulation complete\n")
text = log.read_text()
print("characters :", len(text))
print("lines :", len(text.splitlines()))
print("errors :", text.count("UVM_ERROR"))
print("exists :", log.exists(), "| is_file:", log.is_file())
print("size :", log.stat().st_size)characters : 56
lines : 3
errors : 1
exists : True | is_file: True
size : 56read_text() and write_text() handle the whole file at once, so Chapter 15.7's rule applies unchanged: fine for a config or a short report, never for a simulation log. For anything large, open() and iterate — and open() accepts a Path directly.
6. A Path Is Not a String
The strongest argument, and the one that is structural rather than stylistic:
from pathlib import Path
run = Path("runs/seed_41")
print("joining with / works:", run / "logs")
print("now with +:")
print(run + "logs")joining with / works: runs/seed_41/logs
now with +:
Traceback (most recent call last):
File "nostring.py", line 6, in <module>
print(run + "logs")
~~~~^~~~~~~~
TypeError: unsupported operand type(s) for +: 'PosixPath' and 'str'The mistake raises at the line that made it. Compare Chapter 18.2 Section 2, where "runs/seed_41" + "logs" produced runs/seed_41logs silently and failed much later with a FileNotFoundError naming a directory nobody typed.
That is the real benefit of a type: not that the API is prettier, but that a whole category of error moves from "silent, discovered later, in another function" to "immediate, at the responsible line". It is Chapter 14.2's symptom-versus-cause problem, solved by making the symptom and the cause the same line.
Paths do compare and sort as you would expect, which is what you actually want from them:
from pathlib import Path
paths = [Path("rtl/decoder.sv"), Path("rtl/alu.sv"), Path("tb/env.sv")]
print("sorted :", [str(p) for p in sorted(paths)])
print("equal :", Path("rtl/alu.sv") == Path("rtl/alu.sv"))
print("as a key:", {Path("rtl/alu.sv"): "ok"}[Path("rtl/alu.sv")])sorted : ['rtl/alu.sv', 'rtl/decoder.sv', 'tb/env.sv']
equal : True
as a key: okSortable, comparable and hashable — so a set of Path objects deduplicates correctly, which the two spellings from Chapter 18.2 Section 2 did not.
7. Working With Code That Wants Strings
Most of the standard library accepts a Path directly:
from pathlib import Path
Path("sim.log").write_text("line\n")
with open(Path("sim.log")) as f:
print("open() accepts a Path:", f.read().strip())
print("str() when you need text:", str(Path("runs") / "seed_41"))
print("f-string interpolates :", f"--log={Path('runs/sim.log')}")open() accepts a Path: line
str() when you need text: runs/seed_41
f-string interpolates : --log=runs/sim.logopen(), shutil, os and almost everything else have accepted Path for years. Where a string is genuinely required — building a command line for an external tool, writing to a file that another program parses — str() or an f-string converts.
The practical rule: keep paths as Path objects for as long as possible and convert at the boundary, which is the same shape Chapter 16.16 recommended for dataclasses and dicts.
8. Predict, Run, Observe
from pathlib import Path
p = Path("runs/seed_41/logs/smoke.log")
print("A:", p.suffixes)
print("B:", Path("archive.tar.gz").suffixes, Path("archive.tar.gz").stem)
print("C:", p.parents[1])
print("D:", len(p.parents))
print("E:", Path("sim.log").parent)
print("F:", (Path("a") / "b").is_absolute())
print("G:", Path("a/b") == Path("a") / "b")A: ['.log']
B: ['.tar', '.gz'] archive.tar
C: runs/seed_41
D: 4
E: .
F: False
G: TrueB — suffixes gives the whole list where suffix gives only the last, and stem still strips only one. The .tar.gz trap from Chapter 18.2 Section 7 is present here too, with suffixes as the escape.
C — parents[1] is the grandparent, two levels up. Indexing from the immediate parent outward is the ordering to remember, and Chapter 18.10 is built on it.
E is ., not "". Compare os.path.dirname("sim.log"), which returns an empty string. Path gives you a usable path meaning "here", so joining to it works rather than silently producing a relative path by accident.
G — a path built in one call and one assembled with / compare equal, so it does not matter which spelling a function uses internally.
9. Exercises
Exercise 1 — build one. Construct a log path from a run directory, a subdirectory and a test name using /. Print it and its type.
Exercise 2 — trailing slashes. Build the same path from a base with and without a trailing slash. Confirm the results compare equal.
Exercise 3 — the absolute component. Join an absolute component in the middle and confirm the prefix vanishes. Then detect it with is_relative_to.
Exercise 4 — the parts. For a four-level path, print parts, parents, name, stem and suffix. Say which you would use to test for a generated directory.
Exercise 5 — not a string. Try to + a Path and a str. Compare the error with the result of concatenating two strings in Chapter 18.2.
Exercise 6 — new paths, not mutation. Call with_suffix and confirm the original is unchanged. Name the Module 4 behaviour this matches.
Exercise 7 — the boundary. Write a function that takes a Path and produces a command-line argument string. Say where the conversion belongs.
Exercise 8 — translate a script. Take ten lines of os.path code from your flow and convert it. Confirm the outputs are identical.
10. Summary
Pathis a type for paths, and Modules 15 and 16 used a deliberately narrow slice of it.- The
/operator joins components, inserting exactly one separator regardless of trailing slashes. - Two spellings of one path compare equal, so
Pathobjects deduplicate and sort correctly. - An absolute component still discards everything before it —
pathlibdid not fix that, butis_relative_tomakes it checkable. partsis a tuple, so membership tests are exact wherein str(p)would match a substring.parentsis indexable from the immediate parent outward, andparents[1]anchors every project root in Chapter 18.10.with_suffix,with_nameandwith_stemreturn new paths and leave the original alone.read_textandwrite_texthandle the whole file, so Chapter 15.7's size rule applies.- A
Pathis not a string, so+raises at the responsible line instead of silently producing nonsense. - Keep paths as objects and convert at the boundary with
str()where an external tool needs text.
The next chapter is about the directory shapes a real project uses, and how a script finds what it needs without a hard-coded path per project.
Where this fits
Part of the Python curriculum.
