Pathname#/ joins, and Dir.glob is sorted¶
Level: 201 · anyone building paths with string concatenation, or reaching for os.path habits in Ruby
One line: Pathname is an object with /, basename, extname, parent, children, exist? and read; Dir.glob returns its matches sorted (since Ruby 3.0) and understands ** and {a,b}; File.join, File.expand_path and File.basename are the string-level helpers — and Dir.mktmpdir removes its directory when the block ends, just as File.open closes its file.
Ruby has two levels. The File and Dir class methods work on Strings: File.join joins with one separator however many you gave it, File.expand_path resolves .. against a base, File.basename/extname/dirname split a name, File.exist?/file?/directory?/ftype ask the filesystem, and Dir.glob (alias Dir[]) matches patterns — * within a name, **/ across directories, {rb,md} for alternatives — returning the matches sorted since Ruby 3.0, and skipping dotfiles unless you pass File::FNM_DOTMATCH. Pathname (standard library, require "pathname") wraps a String in an object whose / (also + and join) builds a child path, whose basename, extname, dirname and parent take it apart, whose children lists a directory and whose exist?, read and size ask the filesystem — the same verbs, on an object you can pass around. relative_path_from walks up with .. as needed, cleanpath folds . and .. lexically, descend and ascend enumerate the prefixes. Dir.mktmpdir creates a fresh directory and removes it after its block. The program prints only relative names, so the temporary directory's random name — the one thing here that changes on every run — never appears.
Verified output of pathname_dir_and_glob_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. the runner's cwd is the example's folder: its basename examples
2. __dir__ is this file's folder: its basename examples
3. Pathname("lib") / "app.rb"; + is the same; join #<Pathname:lib/app.rb>; true; lib/deep/util.rb
4. basename, extname, dirname, parent, basename(".rb") ["app.rb", ".rb", "lib", "lib", "app"]
5. exist?, file?, directory?, and a missing name [true, true, true, false]
6. read and size "puts 1\n", 7
7. children of lib, sorted ["lib/app.rb", "lib/deep", "lib/notes.txt"]
8. lib/deep/util.rb relative_path_from(spec) ../lib/deep/util.rb
9. Dir.glob("**/*.rb") is sorted by default (since 3.0) ["lib/app.rb", "lib/deep/util.rb", "spec/app_spec.rb"]; sorted=true
10. braces: Dir.glob("**/*.{rb,md}") ["README.md", "lib/app.rb", "lib/deep/util.rb", "spec/app_spec.rb"]
11. Dir[] is glob; Pathname.glob gives Pathnames ["README.md"]; ["lib/app.rb", "lib/deep/util.rb"]
12. Dir.children(".") vs Dir.entries(".") (sorted) ["README.md", "lib", "spec"] vs [".", "..", "README.md", "lib", "spec"]
13. a glob skips dotfiles unless File::FNM_DOTMATCH ["README.md", "lib", "spec"]; [".", "README.md", "lib", "spec"]
14. File.join collapses the separators lib/deep/util.rb; a/b
15. File.expand_path("../b", "/base/a") /base/b
16. Pathname("a//b/../c").cleanpath a/c
17. File.basename(x, ext), extname, dirname, dirname of a bare ["c", ".gz", "/a/b", "."]
18. sub_ext; absolute? of lib and /lib; relative? ["x.tar.zip", false, true, true]
19. each_filename; descend; ascend ["lib", "app.rb"]; ["lib", "lib/app.rb"]; ["lib/app.rb", "lib"]
20. File.fnmatch: * crosses / unless File::FNM_PATHNAME [true, false]
21. File.ftype of a directory and a file ["directory", "file"]
22. File.rename; File.delete; FileUtils.rm_rf (exist? after) ["README.txt"]; false; false
23. Dir.mktmpdir("prefix-") without a block: made, then removed prefixed=true; gone=true
24. inside Dir.mktmpdir's block the directory exists true
25. after the block it is gone false
Reading the output¶
Rows 1–2 are where the program is: the runner starts it inside its examples/ folder, and __dir__ is that folder too — the base that require_relative resolves against. Rows 3–8 are Pathname: /, + and join build lib/app.rb; basename, extname, dirname and parent (a synonym for dirname) take it apart, and basename(".rb") strips the extension; the predicates and read/size ask the filesystem; children lists lib; and relative_path_from produces the ../lib/deep/util.rb that gets from spec to the file. Rows 9–13 are globbing: **/*.rb found all three files, in sorted order (sorted=true is the point of the title); braces match two extensions in one pattern; Dir[] is the same method and Pathname.glob returns Pathnames; Dir.children omits the . and .. that Dir.entries includes; and a glob skips dotfiles until asked. Rows 14–17 are the String helpers: File.join("a/", "/b") is a/b — the separators collapse — expand_path resolves ../b against /base/a without touching the disk, cleanpath folds a//b/../c to a/c, and File.dirname("c.rb") is ".", never empty. Rows 18–21: sub_ext, the absolute?/relative? pair, each_filename/descend/ascend, File.fnmatch letting * cross a / unless File::FNM_PATHNAME says otherwise, and File.ftype. Row 22 renames, deletes and removes a tree. Rows 23–25 are Dir.mktmpdir: without a block it returns a name you must remove yourself (FileUtils.remove_entry), with a block the directory exists inside and is gone after.
Compared with Python¶
pathlib.Path is Pathname almost verb for verb: / and joinpath (row 3); name, suffix, parent, stem (row 4 — stem is basename(".rb"), and there is one parent where Ruby has dirname and parent as synonyms); exists, is_file, is_dir (row 5); read_text and stat().st_size (row 6); iterdir (row 7). relative_path_from is os.path.relpath, or Path.relative_to(base, walk_up=True) — the keyword arrived in 3.12, and without it relative_to raises ValueError when the target is not inside the base (row 8). glob.glob needs recursive=True for ** and promises no order at all, so the twin sorts it (row 9); it has no brace expansion (row 10); Path.rglob returns Paths (row 11); os.listdir never includes . and .. (row 12); include_hidden=True is FNM_DOTMATCH (row 13). The String helpers differ in their corners: os.path.join("a/", "/b") is /b — an absolute component restarts the path — where File.join gave a/b (row 14); PurePosixPath keeps the .. that cleanpath folds, so posixpath.normpath is the counterpart (row 16); os.path.splitext("c.tar.gz") gives a pair, and os.path.dirname("c.rb") is "", not "." (row 17). fnmatch lets * cross / and has no PATHNAME flag (row 20), and there is no ftype — ask is_dir() (row 21). tempfile.TemporaryDirectory is Dir.mktmpdir with a block and mkdtemp is it without one (rows 23–25). The Python library's Filenames are not text ↗ and What kind of file is this? ↗ measure the two things this page skips — the encoding of a filename, and telling a file's kind from its bytes.
Verified output of pathname_dir_and_glob_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. the runner's cwd is the example's folder: its name examples
2. Path(__file__).resolve().parent is this file's folder examples
3. Path("lib") / "app.rb"; joinpath PosixPath('lib/app.rb'); lib/deep/util.rb
4. name, suffix, parent, parent again, stem ['app.rb', '.rb', 'lib', 'lib', 'app']
5. exists, is_file, is_dir, and a missing name [True, True, True, False]
6. read_text and stat().st_size 'print(1)\n', 9
7. iterdir of lib, sorted ['lib/app.rb', 'lib/deep', 'lib/notes.txt']
8. os.path.relpath(x, 'spec'); Path(x).relative_to('spec', walk_up=True) ../lib/deep/util.rb; ../lib/deep/util.rb
9. glob.glob("**/*.rb", recursive=True), sorted by hand ['lib/app.rb', 'lib/deep/util.rb', 'spec/app_spec.rb']; sorted=(no order is promised)
10. no braces: two globs, joined and sorted ['README.md', 'lib/app.rb', 'lib/deep/util.rb', 'spec/app_spec.rb']
11. glob.glob for strings; Path.rglob gives Paths ['README.md']; ['lib/app.rb', 'lib/deep/util.rb']
12. os.listdir has no . and ..; os.scandir the same ['README.md', 'lib', 'spec'] vs ['README.md', 'lib', 'spec']
13. a glob skips dotfiles unless include_hidden=True ['README.md', 'lib', 'spec']; ['README.md', 'lib', 'spec']
14. os.path.join restarts at an absolute part lib/deep/util.rb; /b
15. posixpath.normpath(posixpath.join('/base/a', '../b')) /base/b
16. posixpath.normpath('a//b/../c'); PurePosixPath keeps .. a/c; a/b/../c
17. Path(x).stem, splitext, dirname, dirname of a bare ['c', ('c.tar', '.gz'), '/a/b', '']
18. with_suffix; is_absolute of lib and /lib; not absolute ['x.tar.zip', False, True, True]
19. parts; parents ['lib', 'app.rb']; ['lib', '.']
20. fnmatch: * crosses /, and there is no PATHNAME flag [True, 'n/a']
21. no ftype: is_dir() and is_file() [True, True]
22. os.rename; os.remove; shutil.rmtree (exists after) ['README.txt']; False; False
23. tempfile.mkdtemp(prefix=) without a with: made, removed prefixed=True; gone=True
24. inside TemporaryDirectory's with the directory exists True
25. after the with it is gone False
Try it¶
- Write
tree(path, indent)that prints a directory as an indented, sorted tree with a/after each directory name, usingPathname#childrenrecursively. Populate a temporary directory withFileUtils.mkdir_pandFileUtils.touch, and print it. - Write
newest_rb(dir)returning the.rbfile underdirwith the largestmtime, usingDir.glob("**/*.rb")andFile.mtime— then explain why its result is not something this library could record in an answer key.
Solution to 1
pathname_dir_and_glob_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Kata: print a directory as an indented tree, directories marked with a
# trailing slash, entries sorted, using Pathname#children recursively.
require "pathname"
require "tmpdir"
require "fileutils"
def tree(path, indent)
path.children.sort.each do |child|
puts "#{indent}#{child.basename}#{child.directory? ? "/" : ""}"
tree(child, indent + " ") if child.directory?
end
end
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
FileUtils.mkdir_p("lib/deep")
FileUtils.mkdir_p("spec")
%w[lib/app.rb lib/deep/util.rb spec/app_spec.rb README.md Rakefile].each { |f| FileUtils.touch(f) }
puts "./"
tree(Pathname("."), " ")
end
end
Verified output of pathname_dir_and_glob_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
File.openwith a block closes the file for you — what to do with a path once you have built itsystemreturns true, false or nil — where the shell's own globbing differs fromDir.globTimeis an instant,Dateis a calendar day — whatFile.mtimereturnsrequire,require_relativeandload—__dir__and howrequire_relativeresolves a path- Filenames are not text ↗ — a filename is bytes; what Python does when they are not valid text
- What kind of file is this? ↗ — telling a file's kind from its first bytes rather than its name
- What a dot means to the shell ↗ — why row 13's glob skips dotfiles: the shell does too