# nobodd: a boot configuration tool for the Raspberry Pi
#
# Copyright (c) 2023-2024 Dave Jones <dave.jones@canonical.com>
# Copyright (c) 2023-2024 Canonical Ltd.
#
# SPDX-License-Identifier: GPL-3.0
import io
import os
import re
import stat
import errno
import fnmatch
import weakref
import warnings
import datetime as dt
from urllib.parse import quote_from_bytes as urlquote_from_bytes
from itertools import zip_longest
from . import lang
from .fat import DirectoryEntry, LongFilenameEntry, lfn_checksum, lfn_valid
from .tools import encode_timestamp, decode_timestamp
[docs]
class FatPath:
"""
A :class:`~pathlib.Path`-like object representing a filepath within an
associated :class:`~nobodd.fs.FatFileSystem`.
There is rarely a need to construct this class directly. Instead, instances
should be obtained via the :attr:`~nobodd.fs.FatFileSystem.root` property
of a :class:`~nobodd.fs.FatFileSystem`. If constructed directly, *fs* is a
:class:`~nobodd.fs.FatFileSystem` instance, and *pathsegments* is the
sequence of strings to be joined with a path separator into the path.
Instances provide almost all the facilities of the :class:`pathlib.Path`
class they are modeled after, including the crucial :meth:`open` method,
:meth:`iterdir`, :meth:`glob`, and :meth:`rglob` for enumerating
directories, :meth:`stat`, :meth:`is_dir`, and :meth:`is_file` for querying
information about files, division for construction of new paths, and all
the usual :attr:`name`, :attr:`parent`, :attr:`stem`, and :attr:`suffix`
attributes. When the :class:`~nobodd.fs.FatFileSystem` is writable, then
:meth:`unlink`, :meth:`touch`, :meth:`mkdir`, :meth:`rmdir`, and
:meth:`rename` may also be used.
Instances are also comparable for the purposes of sorting, but only within
the same :class:`~nobodd.fs.FatFileSystem` instance (comparisons across
file-system instances raise :exc:`TypeError`).
"""
__slots__ = ('_fs', '_index', '_entry', '_parts', '_resolved')
sep = '/'
def __init__(self, fs, *pathsegments):
self._fs = weakref.ref(fs)
self._index = None
self._entry = None
self._parts = get_parts(*pathsegments)
for index, part in enumerate(self._parts):
if index == 0 and not part:
continue # ignore root marker
elif part in ('.', '..'):
continue # ignore path components
elif not lfn_valid(part):
raise ValueError(lang._(
'invalid name {str_self!r}'
.format(str_self=str(self))))
self._resolved = False
def __repr__(self):
return f'{self.__class__.__name__}(<fs>, {str(self)!r})'
def __str__(self):
if self._parts == ('',):
return self.sep
else:
return self.sep.join(self._parts)
def _get_fs(self):
"""
Check the weak reference to the FatFileSystem. If it's still live,
return the strong reference result. If it's disappeared, raise an
:exc:`OSError` exception indicating the file-system has been closed.
"""
fs = self._fs()
if fs is None:
raise OSError(lang._(
'FatFileSystem containing {self!s} is closed'
.format(self=self)))
return fs
@classmethod
def _from_index(cls, fs, index, path=sep):
"""
Internal class method for constructing an instance from *fs* (a
:class:`~nobodd.fs.FatFileSystem` instance), *index* (a
:class:`~nobodd.fs.FatDirectory` instance), and a *path*.
This is only used in the construction of directory instances.
"""
self = cls(fs, path)
self._index = index
self._resolved = True
return self
@classmethod
def _from_entry(cls, fs, index, entry, path=sep):
"""
Internal class method for constructing an instance from *fs* (a
:class:`~nobodd.fs.FatFileSystem` instance), *index* (a
:class:`~nobodd.fs.FatDirectory instance), *entries* (a sequence of
associated :class:`~nobodd.fat.LongFilenameEntry` and
:class:`~nobodd.fat.DirectoryEntry` instances which must exist within
*index*), and a *path*.
"""
if entry.attr & 0x10: # directory
cluster = get_cluster(entry, fs.fat_type)
self = cls._from_index(fs, fs.open_dir(cluster), path)
else:
self = cls(fs, path)
self._index = index
self._entry = entry
self._resolved = True
return self
def _resolve(self):
"""
Internal method which "resolves" a constructed path to find the
corresponding structures on disk (if the path exists).
"""
if self._resolved:
return
assert self._index is None
assert self._entry is None
try:
head, *parts = self.parts
if head != self.sep:
raise ValueError(lang._('relative FatPath cannot be resolved'))
fs = self._get_fs()
path = fs.root
while parts:
path._must_exist()
path._must_be_dir()
head, *parts = parts
try:
path = FatPath._from_entry(
fs, path._index, path._index[head],
str(path / head))
except KeyError:
# Path doesn't exist
return
self._index = path._index
self._entry = path._entry
finally:
self._resolved = True
def _refresh(self):
"""
Internal method which "refreshes" the _entry field to ensure that, if
the first cluster of the file has changed (because the file was emptied
and then re-filled) we have the correct value.
This method should be called before using the cluster of the _entry
field, unless you are certain the cluster cannot be wrong (e.g. the
file backing sub-directories can never be emptied due to the "." and
".." entries so it never changes).
The entry will be refreshed by searching for an entry in the _index
with a matching name, i.e. this is no good if the calling method has
renamed the entry.
"""
if self._resolved:
assert self._index
assert self._entry
assert not self._entry.attr & 0x10, 'no need for _refresh on dirs'
try:
self._entry = self._index[self.name]
except KeyError:
raise FileNotFoundError(lang._(
'Directory entry for {self} disappeared'
.format(self=self)))
else:
self._resolve()
def _must_exist(self):
"""
Internal method which is called to check that a constructed path
actually exists in the file-system. Calls :meth:`_resolve` implicitly.
"""
self._resolve()
if not self.exists():
raise FileNotFoundError(lang._(
'No such file or directory: {self}'.format(self=self)))
def _must_not_exist(self):
"""
Internal method which is called to check that a constructed path
does not exist in the file-system. Calls :meth:`_resolve` implicitly.
"""
self._resolve()
if self.exists():
raise FileExistsError(lang._(
'File exists: {self}'.format(self=self)))
def _must_be_dir(self):
"""
Internal method which is called to check that a constructed path is a
directory. Calls :meth:`_resolve` implicitly.
"""
self._resolve()
if not self.is_dir():
raise NotADirectoryError(lang._(
'Not a directory: {self}'.format(self=self)))
def _must_not_be_dir(self):
"""
Internal method which is called to check that a constructed path is not
a directory. Calls :meth:`_resolve` implicitly.
"""
self._resolve()
if self.is_dir():
raise IsADirectoryError(lang._(
'Is a directory: {self}'.format(self=self)))
[docs]
def open(self, mode='r', buffering=-1, encoding=None, errors=None,
newline=None):
"""
Open the file pointed to by the path, like the built-in
:func:`~io.open` function does. The *mode*, *buffering*, *encoding*,
*errors* and *newline* options are as for the :func:`~io.open`
function. If successful, a :class:`~nobodd.fs.FatFile` instance is
returned.
.. note::
This implementation is read-only, so any modes other than "r" and
"rb" will fail with :exc:`PermissionError`.
"""
fs = self._get_fs()
# Check the mode is valid and matches our expectations (can't open a
# directory, can't read a non-existent file, etc.)
if set(mode) - set('rwaxb+'):
raise ValueError(lang._(
'invalid file mode {mode!r}'.format(mode=mode)))
if len(set(mode) & set('rwax')) != 1:
raise ValueError(lang._(
'must have exactly one of read, write, append, exclusive '
'creation mode'))
if fs.readonly and set(mode) & set('wax+'):
raise PermissionError(lang._('fs is read-only'))
if 'r' in mode:
self._must_exist()
elif 'x' in mode:
self._must_not_exist()
mode = mode.replace('x', 'w')
self._must_not_be_dir()
# If self._entry is None at this point, we must be creating a file
# so get the containing index and make an appropriate DirectoryEntry
if self._entry is None:
date, time, cs = encode_timestamp(dt.datetime.now())
parent = self.parent
parent._must_exist()
parent._must_be_dir()
entry = DirectoryEntry(
# filename and ext of the entry will be ignored; generated
# SFN will be used instead
filename=b'\0' * 8, ext=b'\0' * 3,
# Set DOS "Archive" bit and nothing else
attr=0x20, attr2=0,
cdate=date, ctime=time, ctime_cs=cs,
mdate=date, mtime=time,
adate=date,
first_cluster_lo=0, first_cluster_hi=0, size=0)
parent._index[self.name] = entry
self._index = parent._index
# Look up entry to get generated SFN; this is required so the entry
# we pass to FatFile below has a valid key for later lookups
self._entry = parent._index[self.name]
else:
self._refresh()
# Sanity check the buffering parameter and create the underlying
# FatFile instance with an appropriate mode
if 'b' in mode:
if buffering == 1:
warnings.warn(
RuntimeWarning(
"line buffering (buffering=1) isn't supported in "
"binary mode, the default buffer size will be used"))
buffering = -1
if encoding is not None:
raise ValueError(lang._(
"binary mode doesn't take an encoding argument"))
if errors is not None:
raise ValueError(lang._(
"binary mode doesn't take an errors argument"))
if newline is not None:
raise ValueError(lang._(
"binary mode doesn't take a newline argument"))
f = fs.open_entry(self._index, self._entry, mode)
else:
if buffering == 0:
raise ValueError(lang._("can't have unbuffered text I/O"))
else:
line_buffering = buffering == 1
f = fs.open_entry(self._index, self._entry, mode + 'b')
# Wrap the underlying FatFile instance in whatever's necessary to make
# it text-mode / buffered
if buffering:
if buffering in (-1, 1):
buffering = fs.clusters.size
f = {
(True, False): io.BufferedReader,
(False, True): io.BufferedWriter,
(True, True): io.BufferedRandom,
}[(f.readable(), f.writable())](f, buffering)
if 'b' not in mode:
f = io.TextIOWrapper(
f, encoding=encoding, errors=errors, newline=newline,
line_buffering=line_buffering)
return f
[docs]
def unlink(self, missing_ok=False):
"""
Remove this file. If the path points to a directory, use :meth:`rmdir`
instead.
If *missing_ok* is :data:`False` (the default),
:exc:`FileNotFoundError` is raised if the path does not exist. If
*missing_ok* is :data:`True`, :exc:`FileNotFoundError` exceptions will
be ignored (same behaviour as the POSIX ``rm -f`` command).
"""
fs = self._get_fs()
try:
self._must_exist()
except FileNotFoundError:
if missing_ok:
return
else:
raise
self._must_not_be_dir()
self._refresh()
del self._index[self.name]
for cluster in fs.fat.chain(get_cluster(self._entry, fs.fat_type)):
fs.fat.mark_free(cluster)
self._index = None
self._entry = None
[docs]
def rename(self, target):
"""
Rename this file or directory to the given *target*, and return a new
:class:`FatPath` instance pointing to target. If *target* exists and is
a file, it will be replaced silently. *target* can be either a string
or another path object::
>>> p = fs.root / 'foo'
>>> p.open('w').write('some text')
9
>>> target = fs.root / 'bar'
>>> p.rename(target)
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/bar')
>>> target.read_text()
'some text'
The target path must be absolute. There are no guarantees of atomic
behaviour (in contrast to :func:`os.rename`).
.. note::
:meth:`pathlib.Path.rename` permits relative paths, but interprets
them relative to the working directory which is a concept
:class:`FatPath` does not support.
"""
fs = self._get_fs()
if not isinstance(target, FatPath):
target = FatPath(fs, target)
target_fs = target._get_fs()
if fs is not target_fs:
raise ValueError(lang._(
'Cannot rename between FatFileSystem instances'))
if target.exists():
target._must_not_be_dir()
target._refresh()
target_cluster = get_cluster(target._entry, fs.fat_type)
else:
target.touch()
target_cluster = 0
self._refresh()
target._index[target.name] = self._entry
del self._index[self.name]
if target_cluster:
for cluster in fs.fat.chain(target_cluster):
fs.fat.mark_free(cluster)
self._index = None
self._entry = None
# The entry is mutated by the FatDirectory when setting (specifically
# the SFN is re-written); in case the *target* is not ephemeral, update
# its internal _entry to match to mutated one
target._entry = target._index[target.name]
return target
[docs]
def mkdir(self, mode=0o777, parents=False, exist_ok=False):
"""
Create a new directory at this given path. The *mode* parameter exists
only for compatibility with :class:`pathlib.Path` and is otherwise
ignored. If the path already exists, :exc:`FileExistsError` is raised.
If *parents* is true, any missing parents of this path are created as
needed.
If *parents* is false (the default), a missing parent raises
:exc:`FileNotFoundError`.
If *exist_ok* is false (the default), :exc:`FileExistsError` is raised
if the target directory already exists.
If *exist_ok* is true, :exc:`FileExistsError` exceptions will be
ignored (same behavior as the POSIX ``mkdir -p`` command), but only if
the last path component is not an existing non-directory file.
"""
fs = self._get_fs()
try:
self._must_not_exist()
except FileExistsError:
if exist_ok and self.is_dir():
return
else:
raise
parent = self.parent
try:
parent._must_exist()
except FileNotFoundError:
if parents:
parent.mkdir(mode, parents, exist_ok)
else:
raise
parent._must_be_dir()
date, time, cs = encode_timestamp(dt.datetime.now())
cluster = next(fs.fat.free())
fs.fat.mark_end(cluster)
entry = DirectoryEntry(
# filename and ext of the entry will be ignored and overwritten
# with SFN generated from the associated name
filename=b'\0' * 8, ext=b'\0' * 3,
# Set DOS "Directory" bit and nothing else
attr=0x10, attr2=0,
cdate=date, ctime=time, ctime_cs=cs,
mdate=date, mtime=time,
adate=date,
first_cluster_lo=cluster & 0xFFFF,
first_cluster_hi=cluster >> 16 if fs.fat_type == 'fat32' else 0,
size=0)
parent._index[self.name] = entry
self._index = fs.open_dir(cluster)
self._entry = entry
# Write the minimum entries that all sub-dirs must have: the "." and
# ".." entries, and a terminal EOF entry
self._index['.'] = self._entry
if parent._entry is None:
# Parent is the root
self._index['..'] = self._entry._replace(
first_cluster_hi=0, first_cluster_lo=0)
else:
self._index['..'] = parent._entry
[docs]
def rmdir(self):
"""
Remove this directory. The directory must be empty.
"""
fs = self._get_fs()
self._must_exist()
self._must_be_dir()
if self._entry is not None:
cluster = get_cluster(self._entry, fs.fat_type)
else:
cluster = 0
if cluster == 0:
raise OSError(errno.EACCES, lang._(
'Cannot remove the root directory'))
for item in self.iterdir():
print(repr(item))
raise OSError(errno.ENOTEMPTY, os.strerror(errno.ENOTEMPTY))
parent = self.resolve(strict=False).parent
# NOTE: We already know parent must exist and be a dir
parent._resolve()
del parent._index[self.name]
for cluster in fs.fat.chain(cluster):
fs.fat.mark_free(cluster)
self._index = None
self._entry = None
[docs]
def resolve(self, strict=False):
"""
Make the path absolute, resolving any symlinks. A new :class:`FatPath`
object is returned.
``".."`` components are also eliminated (this is the only method to do
so). If the path doesn't exist and *strict* is :data:`True`,
:exc:`FileNotFoundError` is raised. If *strict* is :data:`False`, the
path is resolved as far as possible and any remainder is appended
without checking whether it exists.
Note that as there is no concept of the "current" directory within
:class:`~nobodd.fs.FatFileSystem`, relative paths cannot be resolved
by this function, only absolute.
"""
fs = self._get_fs()
if not self.is_absolute():
raise ValueError(lang._(
'Cannot resolve relative path {self!r}'.format(self=self)))
parts = [p for p in self._parts if p != '.']
while '..' in parts:
i = parts.index('..')
if i == 1:
del parts[1]
else:
del parts[i - 1:i]
result = FatPath(fs, *parts)
if strict:
result._must_exist()
return result
[docs]
def iterdir(self):
"""
When the path points to a directory, yield path objects of the
directory contents::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> for child in fs.root.iterdir(): child
...
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/foo')
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/bar.txt')
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/setup.cfg')
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/baz')
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/adir')
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/BDIR')
The children are yielded in arbitrary order (the order they are found
in the file-system), and the special entries ``'.'`` and ``'..'`` are
not included.
"""
fs = self._get_fs()
self._must_exist()
self._must_be_dir()
for name, entry in self._index.items():
if name not in ('.', '..'):
yield FatPath._from_entry(
fs, self._index, entry, str(self / name))
[docs]
def match(self, pattern):
"""
Match this path against the provided glob-style pattern. Returns
a :class:`bool` indicating if the match is successful.
If *pattern* is relative, the path may be either relative or absolute,
and matching is done from the right::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> f = fs / 'nobodd' / 'mbr.py'
>>> f
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/mbr.py')
>>> f.match('*.py')
True
>>> f.match('nobodd/*.py')
True
>>> f.match('/*.py')
False
As FAT file-systems are case-insensitive, all matches are likewise
case-insensitive.
"""
if not pattern:
raise ValueError(lang._('empty pattern'))
pat_parts = get_parts(pattern.lower())
parts = self.parts
if len(pat_parts) > len(parts):
return False
for part, pat in zip(reversed(parts), reversed(pat_parts)):
if not fnmatch.fnmatchcase(part.lower(), pat):
return False
return True
def _search(self, parent, parts):
"""
Internal generator function for the implementation of :meth:`glob` and
:meth:`rglob`. Called with *parent*, the containing :class:`FatPath`,
and *parts*, the sequence of path components (in the form of strings)
to match against.
"""
def recursive(parent, parts):
yield from self._search(parent, parts)
for path in parent.iterdir():
if path.is_dir():
yield from recursive(path, parts)
def wildcard(parent, part, parts):
part_re = re.compile(fnmatch.translate(part), re.IGNORECASE)
for path in parent.iterdir():
if part_re.match(path.name):
yield from self._search(path, parts)
def precise(parent, part, parts):
path = parent / part.lower()
if path.exists():
yield from self._search(path, parts)
if not parts:
yield parent
elif parent.is_dir():
part, *parts = parts
if part == '**':
yielded = set()
for path in recursive(parent, parts):
if path._parts not in yielded:
yielded.add(path._parts)
yield path
elif '**' in part:
raise ValueError(lang._(
'invalid pattern: ** can only be an entire component'))
elif '*' in part or '?' in part or '[' in part:
yield from wildcard(parent, part, parts)
else:
yield from precise(parent, part, parts)
[docs]
def glob(self, pattern):
"""
Glob the given relative *pattern* in the directory represented by this
path, yielding matching files (of any kind)::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> sorted((fs.root / 'nobodd').glob('*.py'))
[FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/__init__.py'),
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/disk.py'),
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/fat.py'),
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/fs.py'),
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/gpt.py'),
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/main.py'),
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/mbr.py'),
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/tftp.py'),
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/tools.py')]
Patterns are the same as for :func:`~fnmatch.fnmatch`, with the
addition of "``**``" which means "this directory and all
subdirectories, recursively". In other words, it enables recurisve
globbing.
.. warning::
Using the "``**``" pattern in large directory trees may consume an
inordinate amount of time.
"""
self._must_exist()
if not pattern:
raise ValueError(lang._('Unacceptable pattern'))
pat_parts = get_parts(pattern)
if pat_parts[:1] == ('',):
raise ValueError(lang._('Non-relative patterns are not supported'))
yield from self._search(self, pat_parts)
[docs]
def rglob(self, pattern):
"""
This is like calling :meth:`glob` with a prefix of "``**/``" to the
specified *pattern*.
"""
self._must_exist()
if not pattern:
raise ValueError(lang._('Unacceptable pattern'))
pat_parts = get_parts(pattern)
if pat_parts[:1] == ('',):
raise ValueError(lang._('Non-relative patterns are not supported'))
yield from self._search(self, ('**',) + pat_parts)
[docs]
def stat(self, *, follow_symlinks=True):
"""
Return a :class:`os.stat_result` object containing information about
this path::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> p = (fs.root / 'nobodd' / 'main.py')
>>> p.stat().st_size
388
>>> p.stat().st_ctime
1696606672.02
.. note::
In a FAT file-system, ``atime`` has day resolution, ``mtime`` has
2-second resolution, and ``ctime`` has either 2-second or
millisecond resolution depending on the driver that created it.
Directories have no timestamp information.
The *follow_symlinks* parameter is included purely for compatibility
with :meth:`pathlib.Path.stat`; it is ignored as symlinks are not
supported.
"""
fs = self._get_fs()
self._must_exist()
if self._entry is not None and not (self._entry.attr & 0x10):
self._refresh()
return os.stat_result((
0o444, # mode
get_cluster(self._entry, fs.fat_type), # inode
id(fs), # dev
1, # nlink
0, # uid
0, # gid
self._entry.size, # size
decode_timestamp( # atime
self._entry.adate, 0).timestamp(),
decode_timestamp( # mtime
self._entry.mdate, self._entry.mtime).timestamp(),
decode_timestamp( # ctime
self._entry.cdate, self._entry.ctime,
self._entry.ctime_cs).timestamp()))
else: # self._index is not None is guaranteed by _must_exist
# NOTE: No need to _refresh as the cluster of a sub-directory can
# never change
return os.stat_result((
stat.S_IFDIR | 0o555, # mode
self._index.cluster, # inode
id(fs), # dev
0, # nlink
0, # uid
0, # gid
0, # size
0, # atime
0, # mtime
0)) # ctime
@property
def fs(self):
"""
Returns the :class:`~nobodd.fs.FatFileSystem` instance that this
instance was constructed with.
"""
return self._get_fs()
@property
def root(self):
"""
Returns a string representing the root. This is always "/".
"""
return self.sep
@property
def anchor(self):
"""
Returns the concatenation of the drive and root. This is always "/".
"""
return self.sep
@property
def name(self):
"""
A string representing the final path component, excluding the root::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> p = (fs.root / 'nobodd' / 'main.py')
>>> p.name
'main.py'
"""
try:
return self._parts[-1]
except IndexError:
return ''
@property
def suffix(self):
"""
The file extension of the final component, if any::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> p = (fs.root / 'nobodd' / 'main.py')
>>> p.suffix
'.py'
"""
name = self.name
try:
return name[name.rindex('.'):]
except ValueError:
return ''
@property
def suffixes(self):
"""
A list of the path's file extensions::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> p = (fs.root / 'nobodd.tar.gz')
>>> p.suffixes
['.tar', '.gz']
"""
name = self.name
return ['.' + s for s in name.lstrip('.').split('.')[1:]]
@property
def stem(self):
"""
The final path component, without its suffix::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> p = (fs.root / 'nobodd' / 'main.py')
>>> p.stem
'main'
"""
name = self.name
try:
return name[:name.rindex('.')]
except ValueError:
return name
@property
def parts(self):
"""
A tuple giving access to the path's various components::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> p = (fs.root / 'nobodd' / 'main.py')
>>> p.parts
['/', 'nobodd', 'main.py']
"""
return tuple(
self.sep if index == 0 and part == '' else part
for index, part in enumerate(self._parts))
@property
def parent(self):
"""
The logical parent of the path::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> p = (fs.root / 'nobodd' / 'main.py')
>>> p.parent
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd')
You cannot go past an anchor::
>>> p = (fs.root / 'nobodd' / 'main.py')
>>> p.parent.parent.parent
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/')
"""
fs = self._get_fs()
if len(self._parts) > 1:
return type(self)(fs, *self._parts[:-1])
elif self._parts == ('',) or self._parts == ('.',):
return self
else:
return type(self)(fs, '.')
@property
def parents(self):
"""
An immutable sequence providing access to the logical ancestors of the
path::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> p = (fs.root / 'nobodd' / 'main.py')
>>> p.parents
(FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd'),
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/'))
"""
result = [self.parent]
self = result[-1]
while self.parent is not self:
result.append(self.parent)
self = result[-1]
return tuple(result)
[docs]
def read_text(self, encoding=None, errors=None):
"""
Return the decoded contents of the pointed-to file as a string::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> (fs.root / 'foo').read_text()
'foo\\n'
"""
with self.open(encoding=encoding, errors=errors) as f:
return f.read()
[docs]
def write_text(self, data, encoding=None, errors=None, newline=None):
"""
Open the file pointed to in text mode, write *data* to it, and close
the file::
>>> p = fs.root / 'my_text_file'
>>> p.write_text('Text file contents')
18
>>> p.read_text()
'Text file contents'
An existing file of the same name is overwritten. The optional
parameters have the same meaning as in :meth:`open`.
"""
with self.open(mode='w', encoding=encoding, errors=errors,
newline=newline) as f:
return f.write(data)
[docs]
def read_bytes(self):
"""
Return the binary contents of the pointed-to file as a bytes object::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> (fs.root / 'foo').read_text()
b'foo\\n'
"""
with self.open(mode='rb') as f:
return f.read()
[docs]
def write_bytes(self, data):
"""
Open the file pointed to in bytes mode, write *data* to it, and close
the file::
>>> p = fs.root / 'my_binary_file'
>>> p.write_bytes(b'Binary file contents')
20
>>> p.read_bytes()
b'Binary file contents'
An existing file of the same name is overwritten.
"""
with self.open(mode='wb') as f:
return f.write(data)
[docs]
def touch(self, mode=0o666, exist_ok=True):
"""
Create a file at this given path. The *mode* parameter is only present
for compatibility with :class:`pathlib.Path` and is otherwise ignored.
If the file already exists, the function succeeds if *exist_ok* is
:data:`True` (and its modification time is updated to the current
time), otherwise :exc:`FileExistsError` is raised.
"""
if exist_ok:
with self.open('ab', buffering=0) as f:
f._set_mtime()
else:
with self.open('xb', buffering=0) as f:
pass
[docs]
def exists(self):
"""
Whether the path points to an existing file or directory::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> (fs.root / 'foo').exists()
True
>>> (fs.root / 'fooo').exists()
False
"""
self._resolve()
return self._index is not None and (
self._entry is not None or self._parts == ('',))
[docs]
def is_dir(self):
"""
Return a :class:`bool` indicating whether the path points to a
directory. :data:`False` is also returned if the path doesn't exist.
"""
self._resolve()
return self._index is not None and (
self._entry is None or bool(self._entry.attr & 0x10))
[docs]
def is_file(self):
"""
Returns a :class:`bool` indicating whether the path points to a regular
file. :data:`False` is also returned if the path doesn't exist.
"""
self._resolve()
return self._entry is not None and not (self._entry.attr & 0x10)
[docs]
def is_mount(self):
"""
Returns a :class:`bool` indicating whether the path is a *mount point*.
In this implementation, this is only :data:`True` for the root path.
"""
return self._parts == ('',)
[docs]
def is_absolute(self):
"""
Return whether the path is absolute or not. A path is considered
absolute if it has a "/" prefix.
"""
return self._parts[:1] == ('',)
[docs]
def is_relative_to(self, *other):
"""
Return whether or not this path is relative to the *other* path.
"""
try:
self.relative_to(*other)
except ValueError:
return False
else:
return True
[docs]
def relative_to(self, *other):
"""
Compute a version of this path relative to the path represented by
*other*. If it's impossible, :exc:`ValueError` is raised.
"""
if not other:
raise TypeError(lang._('need at least one argument'))
fs = self._get_fs()
to = type(self)(fs, *other)
n = len(to._parts)
if self._parts[:n] != to._parts:
raise ValueError(lang._(
'{self!r} is not in the subpath of {to!r} OR one path is '
'relative and the other is absolute'
.format(self=self, to=to)))
return type(self)(fs, *self._parts[n:])
[docs]
def joinpath(self, *other):
"""
Calling this method is equivalent to combining the path with each of
the *other* arguments in turn::
>>> fs
<FatFileSystem label='TEST' fat_type='fat16'>
>>> fs.root
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/')
>>> fs.root.joinpath('nobodd')
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd')
>>> fs.root.joinpath('nobodd', 'main.py')
FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/main.py')
"""
fs = self._get_fs()
other = get_parts(*other)
if other[:1] == ('',):
return type(self)(fs, *other)
else:
return type(self)(fs, *self._parts, *other)
[docs]
def with_name(self, name):
"""
Return a new path with the :attr:`name` changed. If the original path
doesn't have a name, :exc:`ValueError` is raised.
"""
fs = self._get_fs()
if not self.name:
raise ValueError(lang._(
'{self!r} has an empty name'.format(self=self)))
if not name:
raise ValueError(lang._(
'invalid name {name!r}'.format(name=name)))
return type(self)(fs, *self._parts[:-1], name)
[docs]
def with_stem(self, stem):
"""
Return a new path with the :attr:`stem` changed. If the original path
doesn't have a name, :exc:`ValueError` is raised.
"""
return self.with_name(stem + self.suffix)
[docs]
def with_suffix(self, suffix):
"""
Return a new path with the :attr:`suffix` changed. If the original path
doesn't have a suffix, the new *suffix* is appended instead. If the
*suffix* is an empty string, the original suffix is removed.
"""
if self.sep in suffix:
raise ValueError(lang._(
'Invalid suffix {suffix!r}'.format(suffix=suffix)))
if suffix and not suffix.startswith('.') or suffix == '.':
raise ValueError(lang._(
'Invalid suffix {suffix!r}'.format(suffix=suffix)))
name = self.name
old_suffix = self.suffix
if not old_suffix:
name = name + suffix
else:
name = name[:-len(old_suffix)] + suffix
return self.with_name(name)
__truediv__ = joinpath
def __eq__(self, other):
if not isinstance(other, FatPath):
return NotImplemented
self_fs = self._get_fs()
other_fs = other._get_fs()
if self_fs is not other_fs:
raise TypeError(lang._(
'comparison is not supported between instances of '
'{self.__class__.__name__} with different file-systems'
.format(self=self)))
return len(self._parts) == len(other._parts) and all(
sp.lower() == op.lower()
for sp, op in zip_longest(self._parts, other._parts, fillvalue=''))
def __le__(self, other):
if not isinstance(other, FatPath):
return NotImplemented
self_fs = self._get_fs()
other_fs = other._get_fs()
if self_fs is not other_fs:
raise TypeError(lang._(
'comparison is not supported between instances of '
'{self.__class__.__name__} with different file-systems'
.format(self=self)))
return all(
sp.lower() <= op.lower()
for sp, op in zip_longest(self.parts, other.parts, fillvalue=''))
def __ne__(self, other):
if not isinstance(other, FatPath):
return NotImplemented
return not self.__eq__(other)
def __lt__(self, other):
if not isinstance(other, FatPath):
return NotImplemented
return self.__le__(other) and not self.__eq__(other)
def __gt__(self, other):
if not isinstance(other, FatPath):
return NotImplemented
return not self.__le__(other)
def __ge__(self, other):
if not isinstance(other, FatPath):
return NotImplemented
return self.__eq__(other) or self.__gt__(other)
[docs]
def get_cluster(entry, fat_type):
"""
Given *entry*, a :class:`~nobodd.fat.DirectoryEntry`, and the *fat_type*
indicating the size of FAT clusters, return the first cluster of the file
associated with the directory entry.
"""
return entry.first_cluster_lo | (
entry.first_cluster_hi << 16 if fat_type == 'fat32' else 0)
def get_parts(*pathsegments):
"""
Given *pathsegments*, split them on the "/" path separator, and return a
:class:`tuple` containing each path segment.
If the path segments refer to an absolute path (beginning with "/") the
first element of the returned :class:`tuple` will be an empty string.
"""
return tuple(
part
for i1, segment in enumerate(pathsegments)
for i2, part in enumerate(str(segment).split('/'))
if i1 == i2 == 0 or part)