Skip to content

Commit c54ea34

Browse files
authored
Version 0.9.4 (#52)
- Adding sync_dirs function - Adding sanitized_input function (Thanks to Dogeek) - Breaking change: Changing find_files to return pathlib objects by default on Python 3.4+ - Removing python 2.6 from travis tests
1 parent bc32f72 commit c54ea34

18 files changed

+314
-49
lines changed

.travis.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ sudo: required
22
dist: trusty
33
language: python
44
python:
5-
- "2.6"
65
- "2.7"
7-
- "3.3"
86
- "3.4"
97
- "3.5"
108
- "3.6"
11-
- "3.7-dev"
129
- "pypy"
10+
matrix:
11+
include:
12+
- python: "3.7"
13+
dist: xenial
14+
sudo: true
1315
before_install:
1416
- sudo apt-get -qq update
1517
- sudo apt-get install -y unrar

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ reporting, all input is greatly appreciated!
55

66
- Clara Griffith (beautiousmax)
77
- Hellowlol
8+
- Dogeek

CHANGES.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
Changelog
22
=========
33

4+
Version 0.9.4
5+
-------------
6+
7+
- Adding sync_dirs function
8+
- Adding sanitized_input function (Thanks to Dogeek)
9+
- Breaking change: Changing find_files to return pathlib objects by default on Python 3.4+
10+
- Removing python 2.6 from travis tests
11+
412
Version 0.9.3
513
-------------
614

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
The MIT License (MIT)
22

3-
Copyright (c) 2014-2017 Chris Griffith
3+
Copyright (c) 2014-2019 Chris Griffith
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy of
66
this software and associated documentation files (the "Software"), to deal in

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ License
394394

395395
The MIT License (MIT)
396396

397-
Copyright (c) 2014-2017 Chris Griffith
397+
Copyright (c) 2014-2019 Chris Griffith
398398

399399
Permission is hereby granted, free of charge, to any person obtaining a copy of
400400
this software and associated documentation files (the "Software"), to deal in

requirements-test.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
pytest
2-
coverage >= 3.6
1+
pytest >= 4.1.1
2+
coverage >= 4.5.2
33
rarfile
44
scandir
55
tox
66
pytest-cov
7+
mock
8+
future

reusables/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from reusables.tasker import *
1919
from reusables.web import *
2020
from reusables.wrappers import *
21+
from reusables.sanitizers import *
2122

2223
__author__ = "Chris Griffith"
23-
__version__ = "0.9.3"
24+
__version__ = "0.9.4"

reusables/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def find(name=None, ext=None, directory=".", match_case=False,
116116
"""
117117
return find_files_list(directory=directory, ext=ext, name=name,
118118
match_case=match_case, disable_glob=disable_glob,
119-
depth=depth)
119+
depth=depth, disable_pathlib=True)
120120

121121

122122
def head(file_path, lines=10, encoding="utf-8", printed=True,

reusables/file_operations.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
# Part of the Reusables package.
55
#
6-
# Copyright (c) 2014-2017 - Chris Griffith - MIT License
6+
# Copyright (c) 2014-2019 - Chris Griffith - MIT License
77
import os
88
import zipfile
99
import tarfile
@@ -12,6 +12,7 @@
1212
import json
1313
import hashlib
1414
import glob
15+
import shutil
1516
from collections import defaultdict
1617
try:
1718
import ConfigParser as ConfigParser
@@ -27,7 +28,7 @@
2728
'directory_duplicates', 'dup_finder', 'file_hash', 'find_files',
2829
'find_files_list', 'join_here', 'join_paths',
2930
'remove_empty_directories', 'remove_empty_files',
30-
'safe_filename', 'safe_path', 'touch']
31+
'safe_filename', 'safe_path', 'touch', 'sync_dirs']
3132

3233
logger = logging.getLogger('reusables')
3334

@@ -168,7 +169,8 @@ def archive(files_to_archive, name="archive.zip", archive_type=None,
168169
raise OSError("File {0} does not exist".format(file_path))
169170
write(file_path)
170171
elif os.path.isdir(file_path):
171-
for nf in find_files(file_path, abspath=False, depth=depth):
172+
for nf in find_files(file_path, abspath=False,
173+
depth=depth, disable_pathlib=True):
172174
write(nf)
173175
except (Exception, KeyboardInterrupt) as err:
174176
logger.exception("Could not archive {0}".format(files_to_archive))
@@ -327,7 +329,7 @@ def config_dict(config_file=None, auto_find=False, verify=True, **cfg_options):
327329
if auto_find:
328330
cfg_files.extend(find_files_list(
329331
current_root if isinstance(auto_find, bool) else auto_find,
330-
ext=(".cfg", ".config", ".ini")))
332+
ext=(".cfg", ".config", ".ini"), disable_pathlib=True))
331333

332334
logger.info("config files to be used: {0}".format(cfg_files))
333335

@@ -462,12 +464,15 @@ def count_files(*args, **kwargs):
462464

463465
def find_files(directory=".", ext=None, name=None,
464466
match_case=False, disable_glob=False, depth=None,
465-
abspath=False, enable_scandir=False):
467+
abspath=False, enable_scandir=False, disable_pathlib=False):
466468
"""
467469
Walk through a file directory and return an iterator of files
468470
that match requirements. Will autodetect if name has glob as magic
469471
characters.
470472
473+
Returns pathlib objects by default with Python versions 3.4 or grater
474+
unless disable_pathlib is enabled.
475+
471476
Note: For the example below, you can use find_files_list to return as a
472477
list, this is simply an easy way to show the output.
473478
@@ -497,8 +502,15 @@ def find_files(directory=".", ext=None, name=None,
497502
:param depth: How many directories down to search
498503
:param abspath: Return files with their absolute paths
499504
:param enable_scandir: on python < 3.5 enable external scandir package
505+
:param disable_pathlib: only return string, not path objects
500506
:return: generator of all files in the specified directory
501507
"""
508+
def pathed(path):
509+
if python_version < (3, 4) or disable_pathlib:
510+
return path
511+
import pathlib
512+
return pathlib.Path(path)
513+
502514
if ext or not name:
503515
disable_glob = True
504516
if not disable_glob:
@@ -522,7 +534,7 @@ def find_files(directory=".", ext=None, name=None,
522534
"either disable glob or not set match_case")
523535
glob_generator = glob.iglob(os.path.join(root, name))
524536
for item in glob_generator:
525-
yield item
537+
yield pathed(item)
526538
continue
527539

528540
for file_name in files:
@@ -538,7 +550,7 @@ def find_files(directory=".", ext=None, name=None,
538550
continue
539551
elif name.lower() not in file_name.lower():
540552
continue
541-
yield os.path.join(root, file_name)
553+
yield pathed(os.path.join(root, file_name))
542554

543555

544556
def remove_empty_directories(root_directory, dry_run=False, ignore_errors=True,
@@ -703,7 +715,7 @@ def directory_duplicates(directory, hash_type='md5', **kwargs):
703715
:return: list of lists of dups"""
704716
size_map, hash_map = defaultdict(list), defaultdict(list)
705717

706-
for item in find_files(directory, **kwargs):
718+
for item in find_files(directory, disable_pathlib=True, **kwargs):
707719
file_size = os.path.getsize(item)
708720
size_map[file_size].append(item)
709721

@@ -853,6 +865,51 @@ def safe_path(path, replacement="_"):
853865
return sanitized_path
854866

855867

868+
def sync_dirs(dir1, dir2, checksums=True, overwrite=False,
869+
only_log_errors=True):
870+
"""
871+
Make sure all files in directory 1 exist in directory 2.
872+
873+
:param dir1: Copy from
874+
:param dir2: Copy too
875+
:param checksums: Use hashes to make sure file contents match
876+
:param overwrite: If sizes don't match, overwrite with file from dir 1
877+
:param only_log_errors: Do not raise copy errors, only log them
878+
:return: None
879+
"""
880+
def cp(f1, f2):
881+
try:
882+
shutil.copy(f1, f2)
883+
except OSError:
884+
if only_log_errors:
885+
logger.error("Could not copy {} to {}".format(f1, f2))
886+
else:
887+
raise
888+
889+
for file in find_files(dir1, disable_pathlib=True):
890+
path_two = os.path.join(dir2, file[len(dir1)+1:])
891+
try:
892+
os.makedirs(os.path.dirname(path_two))
893+
except OSError:
894+
pass # Because exists_ok doesn't exist in 2.x
895+
if os.path.exists(path_two):
896+
if os.path.getsize(file) != os.path.getsize(path_two):
897+
logger.info("File sizes do not match: "
898+
"{} - {}".format(file, path_two))
899+
if overwrite:
900+
logger.info("Overwriting {}".format(path_two))
901+
cp(file, path_two)
902+
elif checksums and (file_hash(file) != file_hash(path_two)):
903+
logger.warning("Files do not match: "
904+
"{} - {}".format(file, path_two))
905+
if overwrite:
906+
logger.info("Overwriting {}".format(file, path_two))
907+
cp(file, path_two)
908+
else:
909+
logger.info("Copying {} to {}".format(file, path_two))
910+
cp(file, path_two)
911+
912+
856913

857914

858915

reusables/log.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from reusables.namespace import Namespace
1818
from reusables.shared_variables import sizes
1919

20-
__all__ = ['log_formats', 'get_logger', 'get_registered_loggers',
20+
__all__ = ['log_formats', 'get_logger', 'setup_logger', 'get_registered_loggers',
2121
'get_file_handler', 'get_stream_handler', 'add_file_handler',
2222
'add_stream_handler', 'add_rotating_file_handler',
2323
'add_timed_rotating_file_handler', 'change_logger_levels',
@@ -43,6 +43,13 @@ def emit(self, record):
4343
logging.NullHandler = NullHandler
4444

4545

46+
def get_logger(*args, **kwargs):
47+
""" Depreciated, use setup_logger"""
48+
warnings.warn("get_logger is changing name to setup_logger",
49+
DeprecationWarning)
50+
return setup_logger(*args, **kwargs)
51+
52+
4653
def get_stream_handler(stream=sys.stderr, level=logging.INFO,
4754
log_format=log_formats.easy_read):
4855
"""
@@ -108,13 +115,6 @@ def setup_logger(module_name=None, level=logging.INFO, stream=sys.stderr,
108115
return new_logger
109116

110117

111-
def get_logger(*args, **kwargs):
112-
""" Depreciated, use setup_logger"""
113-
warnings.warn("get_logger is changing name to setup_logger",
114-
DeprecationWarning)
115-
return setup_logger(*args, **kwargs)
116-
117-
118118
def add_stream_handler(logger=None, stream=sys.stderr, level=logging.INFO,
119119
log_format=log_formats.easy_read):
120120
"""

reusables/sanitizers.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
try:
2+
from collections.abc import Iterable, Callable
3+
except ImportError:
4+
from collections import Iterable, Callable
5+
6+
from reusables.shared_variables import ReusablesError
7+
8+
9+
class InvalidInputError(ReusablesError):
10+
pass
11+
12+
13+
class RetryCountExceededError(ReusablesError):
14+
pass
15+
16+
17+
def _get_input(prompt):
18+
try:
19+
return raw_input(prompt)
20+
except NameError:
21+
return input(prompt)
22+
23+
24+
def sanitized_input(message="", cast_as=None, number_of_retries=-1,
25+
error_message="", valid_input=(), raise_on_invalid=False):
26+
"""
27+
Clean up and cast user input.
28+
29+
:param message: string to show the user (default: "")
30+
:param cast_as: an object to cast the string into. Object must have a __new__
31+
method that can take a string as the first positional argument
32+
and be a subclass of type.
33+
The object should raise a ValueError exception if a
34+
string can't be cast into that object.
35+
cast_as can also be a tuple or a list, which will
36+
chain casts until the end of the list. Casts are chained in
37+
reverse order of the list (to mimic the syntax int(float(x))) (default: str)
38+
:param number_of_retries: number of retries. No limit if n_retries == -1 (default: -1)
39+
:param error_message: message to show the user before asking the input again in
40+
case an error occurs (default: repr of the exception).
41+
Can include {error}.
42+
:param valid_input: an iterable to check if the result is allowed.
43+
:param raise_on_invalid: boolean, whether this function will raise a
44+
reusables.InvalidInputError if the input doesn't match
45+
the valid_input argument.
46+
:return: string literal casted into the cast_as as per that object's rules.
47+
48+
:raises: RetryCountExceededError if the retry count has exceeded the n_retries limit.
49+
50+
51+
Examples:
52+
integer = sanitized_input("How many apples?", int,
53+
error_msg="Please enter a valid number")
54+
# returns an int, will prompt until the user enters an integer.
55+
56+
validated = sanitized_input(">>>", valid_input=["string"], raise_on_invalid=True)
57+
# returns the value "string", and will raise InvalidInputError otherwise.
58+
59+
chain_cast = sanitized_input(">>>", cast_as=[int, float])
60+
# returns an int, prompts like '2.3' won't raise a ValueError Exception.
61+
"""
62+
retry_count = 0
63+
64+
cast_as = cast_as if cast_as is not None else str
65+
cast_objects = list(cast_as) if isinstance(cast_as, Iterable) else (cast_as, )
66+
for cast_obj in cast_objects:
67+
if not isinstance(cast_obj, Callable):
68+
raise ValueError("ValueError: argument 'cast_as'"
69+
"cannot be of type '{}'".format(type(cast_as)))
70+
71+
if not hasattr(valid_input, '__iter__'):
72+
valid_input = (valid_input, )
73+
74+
while retry_count < number_of_retries or number_of_retries == -1:
75+
try:
76+
return_value = _get_input(message)
77+
for cast_obj in reversed(cast_objects):
78+
return_value = cast_obj(return_value)
79+
if valid_input and return_value not in valid_input:
80+
raise InvalidInputError("InvalidInputError: input invalid"
81+
"in function 'sanitized_input' of {}".format(__name__))
82+
return return_value
83+
except (InvalidInputError, ValueError) as err:
84+
if raise_on_invalid and type(err).__name__ == "InvalidInputError":
85+
raise err
86+
print(error_message.format(error=str(err)) if error_message else repr(err))
87+
retry_count += 1
88+
continue
89+
raise RetryCountExceededError("RetryCountExceededError : count exceeded in"
90+
"function 'sanitized_input' of {}".format(__name__))

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,13 @@
3939
classifiers=[
4040
'Programming Language :: Python',
4141
'Programming Language :: Python :: 2',
42-
'Programming Language :: Python :: 2.6',
4342
'Programming Language :: Python :: 2.7',
4443
'Programming Language :: Python :: 3',
4544
'Programming Language :: Python :: 3.3',
4645
'Programming Language :: Python :: 3.4',
4746
'Programming Language :: Python :: 3.5',
4847
'Programming Language :: Python :: 3.6',
48+
'Programming Language :: Python :: 3.7',
4949
'Programming Language :: Python :: Implementation :: CPython',
5050
'Programming Language :: Python :: Implementation :: PyPy',
5151
'Development Status :: 4 - Beta',

0 commit comments

Comments
 (0)