Tcl Part I · Module 12 · Files, Directories, and Paths
When the File Is Not There
Every script that reads a file will eventually be pointed at one that does not exist, or that exists and cannot be read, or that is a directory. What should happen then is a decision, and the time to make it is while you are writing the code rather than while reading a stack trace at the wrong end of a long run. Tcl gives you two ways to handle it and they are not equivalent: one of them has a race condition that the other does not. This chapter shows both, is specific about which to reach for, and covers what the error messages actually say so you can recognise them.
Foundation11 min readTclPart IFilesErrorsRobustness
Part I · Module 12 · Chapter 12.8 · When the File Is Not There
1. What failure looks like
open missing.txt rcouldn't open "missing.txt": no such file or directoryThe message names the path and the reason, which is more than many languages manage. Other reasons appear in the same shape — permission denied for a file you may not read, and so on.
Note that a different command reports the same situation differently:
file size missing.txtcould not read "missing.txt": no such file or directorycouldn't open from open, could not read from file size. Worth knowing when you are grepping a log for one of them.
2. Strategy one: ask first
proc read_checked {path} {
if {![file exists $path]} {
error "no such file: $path"
}
if {![file readable $path]} {
error "cannot read: $path"
}
set fp [open $path r]
set content [read -nonewline $fp]
close $fp
return $content
}
set fp [open present.txt w] ; puts $fp "here" ; close $fp
puts "ok : [read_checked present.txt]"
if {[catch {read_checked absent.txt} msg]} { puts "missing : $msg" }ok : here
missing : no such file: absent.txtThe questions are cheap and never raise:
puts "exists : [file exists absent.txt]"
puts "readable : [file readable absent.txt]"
puts "writable : [file writable absent.txt]"exists : 0
readable : 0
writable : 03. Strategy two: try it and catch
proc read_caught {path} {
if {[catch {open $path r} fp]} {
error "could not open $path: $fp"
}
set code [catch {read -nonewline $fp} content]
close $fp
if {$code} { return -code error $content }
return $content
}
puts "ok : [read_caught present.txt]"
if {[catch {read_caught absent.txt} msg]} { puts "missing : $msg" }ok : here
missing : could not open absent.txt: couldn't open "absent.txt": no such file or directoryWhen catch traps an error, the variable holds the message rather than a channel — which is why $fp appears inside the text there. On success the same variable holds the handle. That dual use is idiomatic and worth reading twice.
4. Why checking first is a race
Here is the argument that decides between them. Between file exists returning 1 and open running, the file can disappear — another process, a cleanup job, a user. The window is small and it is not zero:
set fp [open fragile.txt w] ; puts $fp "here for now" ; close $fp
puts "exists? [file exists fragile.txt]"
# something else removes it at exactly the wrong moment
file delete fragile.txt
if {[catch {open fragile.txt r} result]} {
puts "but open failed: $result"
}exists? 1
but open failed: couldn't open "fragile.txt": no such file or directoryThe check passed and the open still failed. That is contrived here because the delete is on the next line, and in a real script it is a scheduled cleanup, a parallel job, or a network filesystem.
5. So use both, for different jobs
The checks are still worth having — not as the guard, but for the message:
proc read_best {path} {
if {[catch {open $path r} fp]} {
# the open already failed; now work out why, for a useful message
if {![file exists $path]} {
error "input file does not exist: $path"
} elseif {[file isdirectory $path]} {
error "input path is a directory, not a file: $path"
} elseif {![file readable $path]} {
error "input file is not readable (check permissions): $path"
}
error "could not open $path: $fp"
}
set code [catch {read -nonewline $fp} content]
close $fp
if {$code} { return -code error $content }
return $content
}
puts "ok : [read_best present.txt]"
foreach bad {absent.txt} {
if {[catch {read_best $bad} m]} { puts "bad : $m" }
}ok : here
bad : input file does not exist: absent.txtThe open is still the thing that decides. The checks run only after it has failed, so there is no race — and the caller gets a message that says what to do about it rather than just what went wrong.
6. file exists is not enough on its own
A directory exists, is readable, and is not a file:
file mkdir somedir
puts "exists : [file exists somedir]"
puts "readable : [file readable somedir]"
puts "isfile : [file isfile somedir]"
puts "isdirectory : [file isdirectory somedir]"exists : 1
readable : 1
isfile : 0
isdirectory : 1And on this platform, opening it for reading succeeds:
set fp [open somedir r]
puts "opening a directory returned a handle: [string match "file*" $fp]"
close $fpopening a directory returned a handle: 1So a guard built on file exists and file readable passes a directory straight through to code expecting a file.
7. Deciding what missing should mean
Three reasonable answers, and the question that picks one.
Stop. The file is required; without it there is nothing to do:
proc load_required {path} {
if {[catch {open $path r} fp]} {
error "required input missing: $path"
}
set content [read -nonewline $fp]
close $fp
return $content
}Carry on with a default. The file is optional — a user's settings, an override:
proc load_optional {path default} {
if {[catch {open $path r} fp]} { return $default }
set content [read -nonewline $fp]
close $fp
return $content
}
puts "present : [load_optional present.txt "(default)"]"
puts "absent : [load_optional absent.txt "(default)"]"present : here
absent : (default)Create it. The file is something you own and an absent one simply means the first run:
proc load_or_create {path initial} {
if {![file exists $path]} {
set fp [open $path w]
puts -nonewline $fp $initial
close $fp
}
set fp [open $path r]
set content [read -nonewline $fp]
close $fp
return $content
}
puts "first call : [load_or_create state.txt "count 0"]"
puts "second call : [load_or_create state.txt "count 0"]"first call : count 0
second call : count 0The deciding question is the same one Chapter 7.3 §7 asked about missing dictionary keys: can the caller sensibly carry on without it? Required input, no. Optional override, yes. Your own state file, create it.
8. A worked example
Putting both halves together — and noting one place where the order has to be the other way round:
proc load_config {path} {
# A directory must be rejected BEFORE the open, because section 6 showed
# that opening one succeeds. Everything else is handled after the open.
if {[file isdirectory $path]} {
error "config path is a directory: $path"
}
if {[catch {open $path r} fp]} {
if {![file exists $path]} { return [dict create] }
if {![file readable $path]} { error "config not readable: $path" }
error "could not open config $path: $fp"
}
set code [catch {read -nonewline $fp} content]
close $fp
if {$code} { return -code error $content }
set cfg [dict create]
foreach line [split $content "\n"] {
set t [string trim $line]
if {$t eq "" || [string match "#*" $t]} continue
set i [string first "=" $t]
if {$i < 0} continue
dict set cfg [string trim [string range $t 0 [expr {$i-1}]]] \
[string trim [string range $t [expr {$i+1}] end]]
}
return $cfg
}
set fp [open real.cfg w] ; puts $fp "mode = fast" ; puts $fp "runs = 3" ; close $fp
file mkdir dir.cfg
puts "present : [load_config real.cfg]"
puts "absent : '[load_config nothing.cfg]' (empty dict, treated as optional)"
if {[catch {load_config dir.cfg} m]} { puts "a dir : $m" }present : mode fast runs 3
absent : '' (empty dict, treated as optional)
a dir : config path is a directory: dir.cfgThree outcomes: a parsed configuration, an empty one because the file is optional, and a clear error for the case that is a mistake rather than an absence.
9. Exercises
Exercise 1. What is the exact message from open missing.txt r, and how does file size missing.txt differ?
Exercise 2. Why is if {[file exists $p]} { open $p r } not a guarantee?
Exercise 3. What does catch {open $p r} fp leave in fp on success, and on failure?
Exercise 4. Why is file exists insufficient for "a readable ordinary file"?
Exercise 5. Give the three reasonable meanings for a missing file and the question that chooses between them.
Exercise 6. Write a procedure returning a file's contents, or an empty string if it cannot be read, without raising.
Answers
1. couldn't open "missing.txt": no such file or directory. file size says could not read "missing.txt": no such file or directory — the same situation with different wording.
2. Because the file can be removed, renamed or have its permissions changed between the check and the open. The check predicts; only the open decides. This is a time-of-check-to-time-of-use race.
3. On success, the channel handle. On failure, the error message. The same variable carries both, which is why catch {open ...} fp reads oddly the first time.
4. Because a directory also exists and is readable, and on this platform open on a directory for reading even succeeds. file isfile is the question you actually meant.
5. Stop, because the input is required; carry on with a default, because it is optional; or create it, because it is your own state file and this is the first run. The question is whether the caller can sensibly carry on without it.
6.
proc read_or_empty {path} {
if {[catch {open $path r} fp]} { return "" }
set code [catch {read -nonewline $fp} content]
close $fp
if {$code} { return "" }
return $content
}
puts "present : '[read_or_empty present.txt]'"
puts "absent : '[read_or_empty absent.txt]'"present : 'here'
absent : ''10. Remember
openon a missing file sayscouldn't open "path": no such file or directory;file sizesayscould not read.file exists,readableandwritablenever raise — they return 0 for a path that is not there.- Checking before opening is a race. The file can vanish in between; only the
opendecides. - So
catcharoundopenis the part that cannot be wrong, and the checks belong after a failure, to produce a better message. file existsis true for directories too — usefile isfilewhen you mean an ordinary file. Opening a directory for reading succeeds, so this is the one check that must come before theopen; the failure otherwise surfaces atreadasillegal operation on a directory, naming a channel rather than your path.- Decide what missing means: stop, default, or create. The question is whether the caller can carry on without it.
- Say which you chose, so the next person knows it was deliberate.
Where this fits
Part of the Tcl curriculum.
