Skip to content

Commit 418c01f

Browse files
committed
Merge branch 'feature/directory-mode' into develop
2 parents f001725 + 35b8087 commit 418c01f

14 files changed

Lines changed: 249 additions & 64 deletions

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,4 @@ The only difference between those modes is that in the case of name generation,
119119

120120
Actual renaming depends on the mode which determines what filesystem operations will be used.
121121
For the **path** mode, `move` filesystem operation is used, while **name** mode, utilizes the `rename` operation.
122-
If `--dry-run`/`-d` flag has been specified, `DryRunRenamer` is selected and no renaming is performed. All other stages are unaffected by this flag.
122+
If `--dry-run`/`-dr` flag has been specified, `DryRunRenamer` is selected and no renaming is performed. All other stages are unaffected by this flag.

MANUAL.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,22 @@ The output of this command will list all available tag names sorted by name and
117117

118118

119119
# Modes of operation
120-
`tempren` have two main modes of operation: **name** and **path**.
120+
`tempren` have three main modes of operation: **name**, **directory** and **path**.
121121

122122
In the (default) **name** mode (represented by `--name`/`-n` flag), the template is used for filename generation only.
123123
This mode is used most often as it doesn't alter directory structure and focuses just on the files. In this mode, if a path separator (i.e. `/`) is present in the generated path, an error will be reported.
124124

125125
Name mode is used mostly when operating on a single directory or files specified directly in the command-line arguments.
126126

127+
Similarly, with **directory** mode selected (by `--directory`/`-d` flag), template generates new names for directories only - without changing filesystem hierarchy.
128+
129+
> Note: In directory mode only contextual and directory-enabled tags are available
130+
<!--
131+
TODO: `--directory --list` flags combination (in that order) can be used to list supported tags.
132+
-->
133+
134+
> Note: Currently sorting is not available in the directory mode
135+
127136
In the **path** mode (enabled by `--path`/`-p` flag), for each processed file, the template generates a whole, new path (relative to the input directory).
128137
This way you can move (sort) files into dynamically generated catalogues.
129138

@@ -340,7 +349,7 @@ To create an ad-hoc tag, you will need to provide an executable path as an argum
340349
tempren --ad-hoc awk --help awk
341350
```
342351

343-
> Note: When executing the ad-hoc tags, even with `--dry-run`/`-d` flag, `tempren` doesn't have control of the behaviour of the invoked executables.
352+
> Note: When executing the ad-hoc tags, even with `--dry-run`/`-dr` flag, `tempren` doesn't have control of the behaviour of the invoked executables.
344353
> Care should be taken to make sure that the user-provided program doesn't create any undesirable side effects upon template execution.
345354
346355
There are two ways in which an executable associated with an ad-hoc tag can be invoked.
@@ -373,7 +382,7 @@ Program arguments can be passed in the ad-hoc tag arguments but care should be t
373382
separate them correctly as spaces do not delimit the arguments like in the shell environment.
374383
For example, to pass two parameters to the ad-hoc `Program`, the following syntax should be used:
375384
```
376-
%Program("--flag-parameter", "positional")
385+
%Program("--flag", "flag-argument")
377386
```
378387

379388
# Tag aliases
@@ -387,7 +396,7 @@ Tags created from the aliases cannot receive any arguments or context - they are
387396

388397
# Various options
389398
## Dry run
390-
To facilitate discovery-based usage learning, `tempren`'s `--dry-run`/`-d` flag can be used to disable the actual file renaming stage of the pipeline. This way, users can test their templates without making changes to the filesystem.
399+
To facilitate discovery-based usage learning, `tempren`'s `--dry-run`/`-dr` flag can be used to disable the actual file renaming stage of the pipeline. This way, users can test their templates without making changes to the filesystem.
391400
> Note: While dry-run is active, side effects from filtering/sorting template expressions (which are valid Python code), ad-hoc tags
392401
> or even tags themselves may still affect the file system.\
393402
> Be careful not to copy-paste templates that look suspicious.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Renamed: 1211740803547.jpg
7777
<summary>Sorting files into directories based on their MIME type</summary>
7878

7979
```commandline
80-
$ tempren -d --path "%Capitalize(){%Mime(subtype)}/%Name()" ~/Downloads
80+
$ tempren -dr --path "%Capitalize(){%Mime(subtype)}/%Name()" ~/Downloads
8181
Renamed: dotnet-install.sh
8282
to: X-shellscript/dotnet-install.sh
8383
Renamed: openrgb_0.7_amd64_buster_6128731.deb

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ coverage = "^7.2.7"
4343
pre-commit = "^3.5"
4444
mypy = "^1.4.1"
4545
pylint = "^3.0.2"
46+
black = "^24.4.0"
4647

4748
[tool.poetry.extras]
4849
video = ["pymediainfo"]

tempren/cli.py

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from tempren.template.exceptions import TagError, TemplateError
1919

2020
from .pipeline import (
21+
ConfigurationError,
2122
ConflictResolutionStrategy,
2223
FilterType,
2324
InvalidDestinationError,
@@ -134,6 +135,7 @@ def validate_adhoc_tags(
134135
unique_names = set(names)
135136
if len(names) > len(unique_names):
136137
repeating_names = [name for name in unique_names if names.count(name) > 1]
138+
# TODO: Test
137139
for duplicate_name in repeating_names:
138140
executables_with_the_same_name = list(
139141
map(
@@ -159,6 +161,8 @@ def validate_aliases(aliases: List[List[Tuple[TagName, str]]]) -> Dict[TagName,
159161
unique_names = set(names)
160162
if len(names) > len(unique_names):
161163
repeating_names = [name for name in unique_names if names.count(name) > 1]
164+
# TODO: Test
165+
# TODO: Refactor - the same code is found in validate_adhoc_tags
162166
for duplicate_name in repeating_names:
163167
patterns_with_the_same_name = list(
164168
map(
@@ -175,7 +179,7 @@ def validate_aliases(aliases: List[List[Tuple[TagName, str]]]) -> Dict[TagName,
175179
return dict(list_of_tuples)
176180

177181

178-
class SystemExitError(Exception):
182+
class SystemExitError(Exception): # TODO: Use ConfigurationError instead
179183
status: int
180184

181185
def __init__(self, status: int, message: Optional[str]):
@@ -200,9 +204,10 @@ def __call__(
200204
category = registry.category_map[category_name]
201205

202206
all_category_tags = sorted(category.tag_map.items())
203-
if not all_category_tags:
207+
if (
208+
not all_category_tags
209+
): # NOCOVER: Empty categories should not be present in the registry
204210
# There is no tags in the category
205-
# TODO: Empty categories should not be present in the registry
206211
continue
207212
max_name_length = max(
208213
[len(tag_name) for tag_name, factory in all_category_tags]
@@ -245,7 +250,8 @@ def __call__(
245250
tag_factory = registry.get_tag_factory(qualified_name)
246251
except TagError as tag_error:
247252
parser.exit(ErrorCode.USAGE_ERROR, str(tag_error))
248-
raise
253+
# noinspection PyUnreachableCode
254+
raise # NOCOVER: required by mypy
249255
log.info("")
250256
log.info(indent(tag_factory.configuration_signature, " "))
251257
log.info("")
@@ -314,7 +320,6 @@ def _get_help_string(self, action):
314320
return action.help
315321

316322

317-
# CHECK: use pydantic-cli for argument parsing
318323
def process_cli_configuration(argv: List[str]) -> RuntimeConfiguration:
319324
log.debug("Parsing command line arguments")
320325
parser = argparse.ArgumentParser(
@@ -326,7 +331,7 @@ def process_cli_configuration(argv: List[str]) -> RuntimeConfiguration:
326331
)
327332

328333
parser.add_argument(
329-
"-d",
334+
"-dr",
330335
"--dry-run",
331336
action="store_true",
332337
help="Do not perform any renaming - just show expected operation results",
@@ -385,7 +390,15 @@ def process_cli_configuration(argv: List[str]) -> RuntimeConfiguration:
385390
dest="mode",
386391
const=OperationMode.name,
387392
default=OperationMode.name,
388-
help="Use template to generate file name",
393+
help="Use template to generate file name only",
394+
)
395+
operation_mode.add_argument(
396+
"-d",
397+
"--directory",
398+
action="store_const",
399+
dest="mode",
400+
const=OperationMode.directory,
401+
help="Use template to generate directory names only",
389402
)
390403
operation_mode.add_argument(
391404
"-p",
@@ -396,34 +409,6 @@ def process_cli_configuration(argv: List[str]) -> RuntimeConfiguration:
396409
help="Use template to generate relative file path",
397410
)
398411

399-
conflict_resolution_group = parser.add_argument_group("conflict resolution")
400-
conflict_resolution = conflict_resolution_group.add_mutually_exclusive_group()
401-
conflict_resolution.add_argument(
402-
"-cs",
403-
"--conflict-stop",
404-
action="store_true",
405-
default=True,
406-
help="Keep source file name intact and stop",
407-
)
408-
conflict_resolution.add_argument(
409-
"-ci",
410-
"--conflict-ignore",
411-
action="store_true",
412-
help="Keep source file name intact and continue",
413-
)
414-
conflict_resolution.add_argument(
415-
"-co",
416-
"--conflict-override",
417-
action="store_true",
418-
help="Override destination file",
419-
)
420-
conflict_resolution.add_argument(
421-
"-cm",
422-
"--conflict-manual",
423-
action="store_true",
424-
help="Prompt user to resolve conflict manually (choose an option or provide new filename)",
425-
)
426-
427412
filtering_group = parser.add_argument_group("filtering")
428413
filter_mode = filtering_group.add_mutually_exclusive_group()
429414
filter_mode.add_argument(
@@ -469,6 +454,34 @@ def process_cli_configuration(argv: List[str]) -> RuntimeConfiguration:
469454
help="Reverse sorting order",
470455
)
471456

457+
conflict_resolution_group = parser.add_argument_group("conflict resolution")
458+
conflict_resolution = conflict_resolution_group.add_mutually_exclusive_group()
459+
conflict_resolution.add_argument(
460+
"-cs",
461+
"--conflict-stop",
462+
action="store_true",
463+
default=True,
464+
help="Keep source file name intact and stop",
465+
)
466+
conflict_resolution.add_argument(
467+
"-ci",
468+
"--conflict-ignore",
469+
action="store_true",
470+
help="Keep source file name intact and continue",
471+
)
472+
conflict_resolution.add_argument(
473+
"-co",
474+
"--conflict-override",
475+
action="store_true",
476+
help="Override destination file",
477+
)
478+
conflict_resolution.add_argument(
479+
"-cm",
480+
"--conflict-manual",
481+
action="store_true",
482+
help="Prompt user to resolve conflict manually (choose an option or provide new filename)",
483+
)
484+
472485
parser.add_argument(
473486
"template",
474487
type=nonempty_string,
@@ -650,6 +663,9 @@ def main() -> int:
650663
if exc.status != 0:
651664
log.error(exc)
652665
return exc.status
666+
except ConfigurationError as exc:
667+
log.error(exc)
668+
return ErrorCode.USAGE_ERROR
653669
except TemplateEvaluationError as template_evaluation_error:
654670
render_template_evaluation_error(template_evaluation_error)
655671
return ErrorCode.TEMPLATE_EVALUATION_ERROR

tempren/file_sorters.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,8 @@ def _generate_sort_key(self, file: File) -> Tuple:
4343
evaluation_error.expression,
4444
evaluation_error.message,
4545
)
46+
47+
48+
class PathDepthSorter(FileSorter):
49+
def __call__(self, files: Iterable[File]) -> Iterable[File]:
50+
return sorted(files, key=lambda f: (len(f.relative_path.parts),), reverse=True)

tempren/filesystem.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,26 @@ def _gather_in(self, directory: Path) -> Iterable[File]:
8888
)
8989

9090

91+
class RecursiveDirectoryGatherer(FilesystemGatherer):
92+
def __init__(self, start_directory: Path):
93+
self._start_directory = start_directory.absolute()
94+
95+
def gather_files(self) -> Iterable[File]:
96+
yield from self._gather_in(self._start_directory)
97+
98+
def _gather_in(self, directory: Path) -> Iterable[File]:
99+
print(f"Gathering in {directory}")
100+
for dir_entry in directory.iterdir():
101+
if not self._include_path_in_result(dir_entry):
102+
continue
103+
104+
if dir_entry.is_dir():
105+
yield File(
106+
self._start_directory, dir_entry.relative_to(self._start_directory)
107+
)
108+
yield from self._gather_in(dir_entry)
109+
110+
91111
class ExplicitFileGatherer(FileGatherer):
92112
def __init__(self, files: List[Path]):
93113
self._files_to_provide = files

tempren/pipeline.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
RegexPathFileFilter,
1616
TemplateFileFilter,
1717
)
18-
from tempren.file_sorters import TemplateFileSorter
18+
from tempren.file_sorters import PathDepthSorter, TemplateFileSorter
1919
from tempren.filesystem import (
2020
CombinedFileGatherer,
2121
DestinationAlreadyExistsError,
@@ -28,6 +28,7 @@
2828
FlatFileGatherer,
2929
InvalidDestinationError,
3030
PrintingRenamerWrapper,
31+
RecursiveDirectoryGatherer,
3132
RecursiveFileGatherer,
3233
)
3334
from tempren.primitives import CategoryName, File, PathGenerator, Pattern, TagName
@@ -48,6 +49,7 @@ class FilterType(Enum):
4849
class OperationMode(Enum):
4950
name = "name"
5051
path = "path"
52+
directory = "directory"
5153

5254

5355
# TODO: Find a way to keep documentation close to the enum values and use it in argparser/generated help
@@ -283,16 +285,28 @@ def build_pipeline(
283285
pipeline = Pipeline()
284286

285287
file_gatherers: List[FileGatherer] = []
288+
286289
input_directories = filter(lambda p: p.is_dir(), config.input_paths)
287-
for directory in input_directories:
290+
input_files = list(filter(lambda p: p.is_file(), config.input_paths))
291+
if config.mode == OperationMode.directory:
292+
if any(input_files):
293+
log.warning("Ignoring file paths provided in directory mode")
294+
288295
if config.recursive:
289-
file_gatherers.append(RecursiveFileGatherer(directory))
296+
for directory in input_directories:
297+
file_gatherers.append(RecursiveDirectoryGatherer(directory))
290298
else:
291-
file_gatherers.append(FlatFileGatherer(directory))
299+
file_gatherers.append(ExplicitFileGatherer(input_directories))
300+
else:
301+
for directory in input_directories:
302+
if config.recursive:
303+
file_gatherers.append(RecursiveFileGatherer(directory))
304+
else:
305+
file_gatherers.append(FlatFileGatherer(directory))
306+
307+
if any(input_files):
308+
file_gatherers.append(ExplicitFileGatherer(input_files))
292309

293-
input_files = list(filter(lambda p: p.is_file(), config.input_paths))
294-
if any(input_files):
295-
file_gatherers.append(ExplicitFileGatherer(input_files))
296310
pipeline.file_gatherer = CombinedFileGatherer(file_gatherers)
297311
pipeline.file_gatherer.include_hidden = config.include_hidden
298312

@@ -307,7 +321,7 @@ def _compile_template(template_text: str) -> Pattern:
307321

308322
bound_pattern = _compile_template(config.template)
309323

310-
if config.mode == OperationMode.name:
324+
if config.mode == OperationMode.name or config.mode == OperationMode.directory:
311325
pipeline.path_generator = TemplateNameGenerator(bound_pattern)
312326
if config.filter:
313327
if config.filter_type == FilterType.regex:
@@ -318,7 +332,7 @@ def _compile_template(template_text: str) -> Pattern:
318332
bound_filter_pattern = _compile_template(config.filter)
319333
pipeline.file_filter = TemplateFileFilter(bound_filter_pattern)
320334
else:
321-
raise NotImplementedError("Unknown filter type")
335+
raise NotImplementedError("Unknown filter type: " + str(config.filter))
322336
elif config.mode == OperationMode.path:
323337
pipeline.path_generator = TemplatePathGenerator(bound_pattern)
324338
if config.filter:
@@ -330,14 +344,20 @@ def _compile_template(template_text: str) -> Pattern:
330344
bound_filter_pattern = _compile_template(config.filter)
331345
pipeline.file_filter = TemplateFileFilter(bound_filter_pattern)
332346
else:
333-
raise NotImplementedError("Unknown filter type")
347+
raise NotImplementedError("Unknown filter type: " + str(config.filter))
334348
else:
335-
raise NotImplementedError("Unknown operation mode")
349+
raise NotImplementedError("Unknown operation mode: " + str(config.mode))
336350

337351
if config.filter_invert and config.filter is not None:
338352
pipeline.file_filter = FileFilterInverter(pipeline.file_filter)
339353

340-
if config.sort:
354+
if config.mode == OperationMode.directory:
355+
# FIXME: This workaround allows us to mitigate conflicts arising from
356+
# trying to rename child directories after their parent has been renamed
357+
if config.sort:
358+
raise ConfigurationError("Sorting is not available in directory mode")
359+
pipeline.sorter = PathDepthSorter()
360+
elif config.sort:
341361
bound_sorter_pattern = _compile_template(config.sort)
342362
pipeline.sorter = TemplateFileSorter(bound_sorter_pattern, config.sort_invert)
343363

@@ -346,9 +366,9 @@ def _compile_template(template_text: str) -> Pattern:
346366

347367
if config.dry_run:
348368
pipeline.renamer = DryRunRenamer()
349-
# FIXME: Use DryRunMover for path mode?
369+
# FIXME: Use custom DryRunMover for path mode?
350370
else:
351-
if config.mode == OperationMode.name:
371+
if config.mode == OperationMode.name or config.mode == OperationMode.directory:
352372
pipeline.renamer = FileRenamer()
353373
elif config.mode == OperationMode.path:
354374
pipeline.renamer = FileMover()

0 commit comments

Comments
 (0)