Module refinery.units.meta.ef
Expand source code Browse git
from __future__ import annotations
import fnmatch
import os
import os.path
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Iterable, Iterator
from refinery.lib.meta import MV, metavars
from refinery.lib.structures import MemoryFile
from refinery.lib.tools import exception_to_string
from refinery.lib.types import Param, bounds
from refinery.units import Arg, Unit
_ERROR_IGNORES = {
'nt': {'system volume information'}
}
def _is_link(path: str) -> bool:
"""
Whether the given path is a symbolic link or a junction.
"""
if sys.version_info >= (3, 12):
return os.path.islink(path) or os.path.isjunction(path)
try:
return bool(os.readlink(path))
except OSError:
return False
def _contains(directory: str, path: str) -> bool:
"""
Whether the given path is the given directory or below it; both are real paths.
"""
directory = os.path.normcase(directory)
path = os.path.normcase(path)
return path == directory or path.startswith(directory.rstrip(os.sep) + os.sep)
class _SkipErrors:
"""
A context that suppresses errors while one path is scanned, and logs the first error of each
kind for that path.
"""
def __init__(self, unit: ef):
self.unit = unit
self._history: set[type] = set()
self._message: dict[type, str | None] = {
ValueError: (
None
), PermissionError: (
'access error while scanning: {}'
), FileNotFoundError: (
'file unexpectedly not found: {}'
), OSError: (
'system error while scanning: {}'
), Exception: (
'unknown error while reading: {}'
),
}
self.path = None
def reset(self, path):
self._history.clear()
self.path = path
return self
def __enter__(self):
return self
def __exit__(self, et, ev, trace):
if et is None:
return False
for t, msg in self._message.items():
if issubclass(et, t):
if t not in self._history:
self._history.add(t)
if msg is not None:
self.unit.log_info(msg.format(self.path))
return True
else:
return False
class ef(Unit):
"""
Emit files matching a glob pattern from disk.
Short for "emit file". The unit reads files from disk and outputs them individually. Has the
ability to read large files in chunks.
"""
def __init__(self,
*filenames: Param[str, Arg.String(metavar='FILEMASK', nargs='+', help=(
'A list of file masks. Each matching file will be read from disk and emitted. The file '
'masks can include format string expressions which will be substituted from the current'
' meta variables. The masks can use wild-card expressions, but this feature is disabled'
' by default on Posix platforms, where it has to be enabled explicitly using the -w '
'switch. On Windows, the feature is enabled by default and can be disabled using the -t'
' switch.'
))],
list: Param[bool, Arg.Switch('-l', help='Only lists files with metadata.')] = False,
meta: Param[bool, Arg.Switch('-m', help=(
'Adds the atime, mtime, ctime, and size metadata variables.'
))] = False,
size: Param[slice | None, Arg.Bounds('-s', help=(
'If specified, only files are read whose size is in the given range.'))] = None,
read: Param[int, Arg.Number('-r', help=(
'If specified, files will be read in chunks of size N and each chunk is emitted as one '
'element in the output list.'
))] = 0,
wild: Param[bool, Arg.Switch('-w', group='W',
help='Force use of wildcard patterns in file masks.')] = False,
tame: Param[bool, Arg.Switch('-t', group='W',
help='Disable wildcard patterns in file masks.')] = False,
symlinks: Param[bool, Arg.Switch('-y',
help='Follow symbolic links and junctions, these are ignored by default.')] = False,
linewise: Param[bool, Arg.Switch('-i', help=(
'Read the file linewise. By default, one line is read at a time. In line mode, the '
'--read argument can be used to read the given number of lines in each chunk.'
))] = False
):
if wild and tame:
raise ValueError('Cannot be both wild and tame!')
super().__init__(
size=size,
read=read,
list=list,
meta=meta,
wild=wild,
tame=tame,
symlinks=symlinks,
linewise=linewise,
filenames=filenames
)
def _read_chunks(self, fd):
while True:
buffer = fd.read(self.args.read)
if not buffer:
break
yield buffer
def _read_lines(self, fd):
count = self.args.read or 1
if count == 1:
while True:
buffer = fd.readline()
if not buffer:
break
yield buffer
return
with MemoryFile() as out:
while True:
for _ in range(count):
buffer = fd.readline()
if not buffer:
break
out.write(buffer)
if not out.tell():
break
yield out.getvalue()
out.seek(0)
out.truncate()
def _absolute_path(self, path_string: str):
path = Path(path_string).resolve().absolute()
if os.name == 'nt' and not path.parts[0].startswith('\\\\?\\'):
# The pathlib glob method will simply fail mid-traversal if it attempts to descend into
# a folder or to a file whose path exceeds MAX_PATH on Windows. As a workaround, we use
# UNC paths throughout and truncate to relative paths after enumeration.
path = Path(F'\\\\?\\{path!s}')
return path
def _glob(self, pattern: str) -> Iterable[Path]:
if pattern.endswith('**'):
pattern += '/*'
wildcard = re.search(R'[\[\?\*]', pattern)
if wildcard is None:
yield self._absolute_path(pattern)
return
k = wildcard.start()
base, pattern = pattern[:k], pattern[k:]
path = self._absolute_path(base or '.')
last = path.parts[-1]
if base.endswith(last):
# /base/something.*
pattern = F'{last}{pattern}'
path = path.parent
parts = tuple(part for part in re.split(R'[\\/]', pattern) if part)
yield from self._matches(path, os.path.realpath(path), parts)
def _entries(self, directory: Path) -> list[os.DirEntry]:
"""
The entries of the given directory; a directory that cannot be listed has none.
"""
try:
with os.scandir(directory) as scan:
return list(scan)
except Exception as error:
ignore = _ERROR_IGNORES.get(os.name, set())
if not any(part.lower() in ignore for part in directory.parts):
self.log_warn(F'error calling scandir, {exception_to_string(error)}: {directory}')
return []
def _entered(self, entry: os.DirEntry, real: str) -> str | None:
"""
The real path of the directory of the given entry, if the walk enters it; the given real
path is that of the directory that lists the entry. A link or junction is entered only
when the unit follows them, and not when it leads to a directory that the walk is in.
"""
try:
if not entry.is_dir():
return None
except OSError:
return None
if not _is_link(entry.path):
return os.path.join(real, entry.name)
if not self.args.symlinks:
return None
try:
target = os.path.realpath(entry.path, strict=True)
except OSError:
return None
if _contains(target, real):
self.log_warn(F'file system loop at: {entry.path}')
return None
return target
def _matches(self, directory: Path, real: str, parts: tuple[str, ...]) -> Iterator[Path]:
"""
The paths below the given directory that match the given components of a pattern; the
component `**` matches any number of directories. The real path is that of the directory.
"""
head, rest = parts[0], parts[1:]
if head == '**':
if rest:
yield from self._matches(directory, real, rest)
for entry in self._entries(directory):
if (inner := self._entered(entry, real)) is not None:
yield from self._matches(directory / entry.name, inner, parts)
return
for entry in self._entries(directory):
if not fnmatch.fnmatch(entry.name, head):
continue
if not rest:
yield directory / entry.name
elif (inner := self._entered(entry, real)) is not None:
yield from self._matches(directory / entry.name, inner, rest)
def process(self, data):
meta = metavars(data)
size = self.args.size
size = size and bounds[size]
wild = (os.name == 'nt' or self.args.wild) and not self.args.tame
root = self._absolute_path('.')
paths = self._glob if wild else lambda mask: [self._absolute_path(mask)]
do_meta = self.args.meta
do_stat = size or do_meta
for mask in self.args.filenames:
mask = meta.format_str(mask, self.codec, [data])
self.log_debug('scanning for mask:', mask)
kwargs = dict()
skip_errors = _SkipErrors(self)
for path in paths(mask):
skip_errors.reset(path)
filesize = -1
with skip_errors:
path = path.relative_to(root)
with skip_errors:
if wild and not path.is_file():
continue
with skip_errors:
if do_stat:
stat = path.stat()
filesize = stat.st_size
if do_meta:
atime = stat.st_atime
mtime = stat.st_mtime
ctime = stat.st_ctime
try:
btime = stat.st_birthtime
except AttributeError:
btime = ctime
kwargs.update(
fsize=filesize,
**{
MV.BTIME:
datetime.fromtimestamp(btime).isoformat(' ', 'seconds'),
MV.ATIME:
datetime.fromtimestamp(atime).isoformat(' ', 'seconds'),
MV.CTIME:
datetime.fromtimestamp(ctime).isoformat(' ', 'seconds'),
MV.MTIME:
datetime.fromtimestamp(mtime).isoformat(' ', 'seconds'),
}
)
if size is not None and filesize not in size:
continue
with skip_errors:
if self.args.list:
yield self.labelled(str(path).encode(self.codec), **kwargs)
continue
with path.open('rb') as stream:
if self.args.linewise:
yield from self._read_lines(stream)
elif self.args.read:
yield from self._read_chunks(stream)
else:
contents = stream.read()
self.log_info(lambda: F'reading: {path!s} ({len(contents)} bytes)')
yield self.labelled(
contents,
**{MV.PATH: path.as_posix()},
**kwargs
)
Classes
class ef (*filenames, list=False, meta=False, size=None, read=0, wild=False, tame=False, symlinks=False, linewise=False)-
Emit files matching a glob pattern from disk.
Short for "emit file". The unit reads files from disk and outputs them individually. Has the ability to read large files in chunks.
Expand source code Browse git
class ef(Unit): """ Emit files matching a glob pattern from disk. Short for "emit file". The unit reads files from disk and outputs them individually. Has the ability to read large files in chunks. """ def __init__(self, *filenames: Param[str, Arg.String(metavar='FILEMASK', nargs='+', help=( 'A list of file masks. Each matching file will be read from disk and emitted. The file ' 'masks can include format string expressions which will be substituted from the current' ' meta variables. The masks can use wild-card expressions, but this feature is disabled' ' by default on Posix platforms, where it has to be enabled explicitly using the -w ' 'switch. On Windows, the feature is enabled by default and can be disabled using the -t' ' switch.' ))], list: Param[bool, Arg.Switch('-l', help='Only lists files with metadata.')] = False, meta: Param[bool, Arg.Switch('-m', help=( 'Adds the atime, mtime, ctime, and size metadata variables.' ))] = False, size: Param[slice | None, Arg.Bounds('-s', help=( 'If specified, only files are read whose size is in the given range.'))] = None, read: Param[int, Arg.Number('-r', help=( 'If specified, files will be read in chunks of size N and each chunk is emitted as one ' 'element in the output list.' ))] = 0, wild: Param[bool, Arg.Switch('-w', group='W', help='Force use of wildcard patterns in file masks.')] = False, tame: Param[bool, Arg.Switch('-t', group='W', help='Disable wildcard patterns in file masks.')] = False, symlinks: Param[bool, Arg.Switch('-y', help='Follow symbolic links and junctions, these are ignored by default.')] = False, linewise: Param[bool, Arg.Switch('-i', help=( 'Read the file linewise. By default, one line is read at a time. In line mode, the ' '--read argument can be used to read the given number of lines in each chunk.' ))] = False ): if wild and tame: raise ValueError('Cannot be both wild and tame!') super().__init__( size=size, read=read, list=list, meta=meta, wild=wild, tame=tame, symlinks=symlinks, linewise=linewise, filenames=filenames ) def _read_chunks(self, fd): while True: buffer = fd.read(self.args.read) if not buffer: break yield buffer def _read_lines(self, fd): count = self.args.read or 1 if count == 1: while True: buffer = fd.readline() if not buffer: break yield buffer return with MemoryFile() as out: while True: for _ in range(count): buffer = fd.readline() if not buffer: break out.write(buffer) if not out.tell(): break yield out.getvalue() out.seek(0) out.truncate() def _absolute_path(self, path_string: str): path = Path(path_string).resolve().absolute() if os.name == 'nt' and not path.parts[0].startswith('\\\\?\\'): # The pathlib glob method will simply fail mid-traversal if it attempts to descend into # a folder or to a file whose path exceeds MAX_PATH on Windows. As a workaround, we use # UNC paths throughout and truncate to relative paths after enumeration. path = Path(F'\\\\?\\{path!s}') return path def _glob(self, pattern: str) -> Iterable[Path]: if pattern.endswith('**'): pattern += '/*' wildcard = re.search(R'[\[\?\*]', pattern) if wildcard is None: yield self._absolute_path(pattern) return k = wildcard.start() base, pattern = pattern[:k], pattern[k:] path = self._absolute_path(base or '.') last = path.parts[-1] if base.endswith(last): # /base/something.* pattern = F'{last}{pattern}' path = path.parent parts = tuple(part for part in re.split(R'[\\/]', pattern) if part) yield from self._matches(path, os.path.realpath(path), parts) def _entries(self, directory: Path) -> list[os.DirEntry]: """ The entries of the given directory; a directory that cannot be listed has none. """ try: with os.scandir(directory) as scan: return list(scan) except Exception as error: ignore = _ERROR_IGNORES.get(os.name, set()) if not any(part.lower() in ignore for part in directory.parts): self.log_warn(F'error calling scandir, {exception_to_string(error)}: {directory}') return [] def _entered(self, entry: os.DirEntry, real: str) -> str | None: """ The real path of the directory of the given entry, if the walk enters it; the given real path is that of the directory that lists the entry. A link or junction is entered only when the unit follows them, and not when it leads to a directory that the walk is in. """ try: if not entry.is_dir(): return None except OSError: return None if not _is_link(entry.path): return os.path.join(real, entry.name) if not self.args.symlinks: return None try: target = os.path.realpath(entry.path, strict=True) except OSError: return None if _contains(target, real): self.log_warn(F'file system loop at: {entry.path}') return None return target def _matches(self, directory: Path, real: str, parts: tuple[str, ...]) -> Iterator[Path]: """ The paths below the given directory that match the given components of a pattern; the component `**` matches any number of directories. The real path is that of the directory. """ head, rest = parts[0], parts[1:] if head == '**': if rest: yield from self._matches(directory, real, rest) for entry in self._entries(directory): if (inner := self._entered(entry, real)) is not None: yield from self._matches(directory / entry.name, inner, parts) return for entry in self._entries(directory): if not fnmatch.fnmatch(entry.name, head): continue if not rest: yield directory / entry.name elif (inner := self._entered(entry, real)) is not None: yield from self._matches(directory / entry.name, inner, rest) def process(self, data): meta = metavars(data) size = self.args.size size = size and bounds[size] wild = (os.name == 'nt' or self.args.wild) and not self.args.tame root = self._absolute_path('.') paths = self._glob if wild else lambda mask: [self._absolute_path(mask)] do_meta = self.args.meta do_stat = size or do_meta for mask in self.args.filenames: mask = meta.format_str(mask, self.codec, [data]) self.log_debug('scanning for mask:', mask) kwargs = dict() skip_errors = _SkipErrors(self) for path in paths(mask): skip_errors.reset(path) filesize = -1 with skip_errors: path = path.relative_to(root) with skip_errors: if wild and not path.is_file(): continue with skip_errors: if do_stat: stat = path.stat() filesize = stat.st_size if do_meta: atime = stat.st_atime mtime = stat.st_mtime ctime = stat.st_ctime try: btime = stat.st_birthtime except AttributeError: btime = ctime kwargs.update( fsize=filesize, **{ MV.BTIME: datetime.fromtimestamp(btime).isoformat(' ', 'seconds'), MV.ATIME: datetime.fromtimestamp(atime).isoformat(' ', 'seconds'), MV.CTIME: datetime.fromtimestamp(ctime).isoformat(' ', 'seconds'), MV.MTIME: datetime.fromtimestamp(mtime).isoformat(' ', 'seconds'), } ) if size is not None and filesize not in size: continue with skip_errors: if self.args.list: yield self.labelled(str(path).encode(self.codec), **kwargs) continue with path.open('rb') as stream: if self.args.linewise: yield from self._read_lines(stream) elif self.args.read: yield from self._read_chunks(stream) else: contents = stream.read() self.log_info(lambda: F'reading: {path!s} ({len(contents)} bytes)') yield self.labelled( contents, **{MV.PATH: path.as_posix()}, **kwargs )Ancestors
Subclasses
Class variables
var reverse-
The type of the None singleton.
Inherited members