diff --git a/.black.toml b/.black.toml new file mode 100644 index 0000000..9414fb7 --- /dev/null +++ b/.black.toml @@ -0,0 +1,15 @@ +[tool.black] +line-length = 120 +target-version = ['py36', 'py37', 'py38'] +exclude = ''' +/( + \.eggs + | \.git + | \.idea + | \.pytest_cache + | _build + | build + | dist + | venv +)/ +''' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..77d9fdf --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.5.0 + hooks: + - id: mixed-line-ending + - id: trailing-whitespace + - id: requirements-txt-fixer + - id: fix-encoding-pragma + - id: check-byte-order-marker + - id: debug-statements + - id: check-yaml +- repo: https://github.com/ambv/black + rev: stable + hooks: + - id: black + args: [--config=.black.toml] diff --git a/CHANGES.rst b/CHANGES.rst index cca11b2..2c8fb16 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,15 @@ Changelog ========= +Version 0.9.6 +------------- + +- Adding #58 defaultlist (Thanks to Dogeek) +- Adding LoggerIOWrapper (Thanks to Dogeek) +- Adding log_it (Thanks to Dogeek) +- Adding #57 singleton implementation (Thanks to Dogeek) +- Changing line length to 120 and using `black` standard formatter + Version 0.9.5 ------------- diff --git a/LICENSE b/LICENSE index 4ae1574..790b094 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014-2019 Chris Griffith +Copyright (c) 2014-2020 Chris Griffith Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.rst b/README.rst index 8af592f..fe983dd 100644 --- a/README.rst +++ b/README.rst @@ -55,8 +55,8 @@ Please note this is currently in development. Any item in development prior to a major version (1.x, 2.x) may change. Once at a major version, no breaking changes are planned to occur within that version. -What's in the box ------------------ +What's included +--------------- General Helpers and File Management ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -394,7 +394,7 @@ License The MIT License (MIT) -Copyright (c) 2014-2019 Chris Griffith +Copyright (c) 2014-2020 Chris Griffith Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/doc/source/conf.py b/doc/source/conf.py index 7ef8135..eca9eda 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -21,7 +21,7 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) root = os.path.abspath(os.path.dirname(__file__)) @@ -40,49 +40,49 @@ # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', + "sphinx.ext.autodoc", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'Reusables' -copyright = '2014-2016, Chris Griffith' +project = "Reusables" +copyright = "2014-2020, Chris Griffith" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = attrs['version'] +version = attrs["version"] # The full version, including alpha/beta/rc tags. -release = attrs['version'] +release = attrs["version"] # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -90,10 +90,10 @@ # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). @@ -101,156 +101,150 @@ # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'Reusablesdoc' +htmlhelp_basename = "Reusablesdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'Reusables.tex', 'Reusables Documentation', - 'Chris Griffith', 'manual'), + ("index", "Reusables.tex", "Reusables Documentation", "Chris Griffith", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'reusables', 'Reusables Documentation', - ['Chris Griffith'], 1) -] +man_pages = [("index", "reusables", "Reusables Documentation", ["Chris Griffith"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -259,19 +253,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'Reusables', 'Reusables Documentation', - 'Chris Griffith', 'Reusables', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "Reusables", + "Reusables Documentation", + "Chris Griffith", + "Reusables", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/requirements-test.txt b/requirements-test.txt index 96bd289..321b15b 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,8 +1,8 @@ -pytest >= 4.1.1 coverage >= 4.5.2 +future +mock +pytest >= 4.1.1 +pytest-cov rarfile scandir tox -pytest-cov -mock -future diff --git a/reusables/__init__.py b/reusables/__init__.py index 38e8481..535e983 100644 --- a/reusables/__init__.py +++ b/reusables/__init__.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License from __future__ import absolute_import from reusables.string_manipulation import * @@ -20,6 +20,7 @@ from reusables.wrappers import * from reusables.sanitizers import * from reusables.other import * +from reusables.default_list import * __author__ = "Chris Griffith" -__version__ = "0.9.5" +__version__ = "0.9.6" diff --git a/reusables/cli.py b/reusables/cli.py index 3d1246c..229af0b 100644 --- a/reusables/cli.py +++ b/reusables/cli.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License """ Functions to only be in an interactive instances to ease life. """ from __future__ import absolute_import import os @@ -17,8 +17,7 @@ from reusables.file_operations import find_files_list from reusables.log import add_stream_handler -__all__ = ['cmd', 'pushd', 'popd', 'pwd', 'cd', 'ls', - 'find', 'head', 'cat', 'tail', 'cp'] +__all__ = ["cmd", "pushd", "popd", "pwd", "cd", "ls", "find", "head", "cat", "tail", "cp"] _logger = logging.getLogger("reusables.cli") add_stream_handler("reusables.cli") @@ -26,8 +25,7 @@ _saved_paths = [] -def cmd(command, ignore_stderr=False, raise_on_return=False, timeout=None, - encoding="utf-8"): +def cmd(command, ignore_stderr=False, raise_on_return=False, timeout=None, encoding="utf-8"): """ Run a shell command and have it automatically decoded and printed :param command: Command to run as str @@ -91,8 +89,7 @@ def ls(params="", directory=".", printed=True): :param printed: If you're using this, you probably wanted it just printed :return: if not printed, you can parse it yourself """ - command = "{0} {1} {2}".format("ls" if not win_based else "dir", - params, directory) + command = "{0} {1} {2}".format("ls" if not win_based else "dir", params, directory) response = run(command, shell=True) # Shell required for windows response.check_returncode() if printed: @@ -101,8 +98,7 @@ def ls(params="", directory=".", printed=True): return response.stdout -def find(name=None, ext=None, directory=".", match_case=False, - disable_glob=False, depth=None): +def find(name=None, ext=None, directory=".", match_case=False, disable_glob=False, depth=None): """ Designed for the interactive interpreter by making default order of find_files faster. @@ -114,13 +110,18 @@ def find(name=None, ext=None, directory=".", match_case=False, :param depth: How many directories down to search :return: list of all files in the specified directory """ - return find_files_list(directory=directory, ext=ext, name=name, - match_case=match_case, disable_glob=disable_glob, - depth=depth, disable_pathlib=True) - - -def head(file_path, lines=10, encoding="utf-8", printed=True, - errors='strict'): + return find_files_list( + directory=directory, + ext=ext, + name=name, + match_case=match_case, + disable_glob=disable_glob, + depth=depth, + disable_pathlib=True, + ) + + +def head(file_path, lines=10, encoding="utf-8", printed=True, errors="strict"): """ Read the first N lines of a file, defaults to 10 @@ -147,7 +148,7 @@ def head(file_path, lines=10, encoding="utf-8", printed=True, return data -def cat(file_path, encoding="utf-8", errors='strict'): +def cat(file_path, encoding="utf-8", errors="strict"): """ .. code: @@ -172,8 +173,7 @@ def cat(file_path, encoding="utf-8", errors='strict'): print(f.read().decode(encoding)) -def tail(file_path, lines=10, encoding="utf-8", - printed=True, errors='strict'): +def tail(file_path, lines=10, encoding="utf-8", printed=True, errors="strict"): """ A really silly way to get the last N lines, defaults to 10. @@ -221,11 +221,9 @@ def cp(src, dst, overwrite=False): for item in src: source = os.path.expanduser(item) - destination = (dst if not dst_folder else - os.path.join(dst, os.path.basename(source))) + destination = dst if not dst_folder else os.path.join(dst, os.path.basename(source)) if not overwrite and os.path.exists(destination): - _logger.warning("Not replacing {0} with {1}, overwrite not enabled" - "".format(destination, source)) + _logger.warning("Not replacing {0} with {1}, overwrite not enabled" "".format(destination, source)) continue shutil.copy(source, destination) diff --git a/reusables/default_list.py b/reusables/default_list.py new file mode 100644 index 0000000..a3b3366 --- /dev/null +++ b/reusables/default_list.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +class defaultlist(list): + def __init__(self, *args, factory=None, **kwargs): + super().__init__(*args, **kwargs) + self.factory = factory + + def __getitem__(self, index): + if index >= len(self): + diff = index - len(self) + 1 + for i in range(diff): + self.append(self.factory()) + return super().__getitem__(index) diff --git a/reusables/dt.py b/reusables/dt.py index 46d1bc6..6b11a92 100644 --- a/reusables/dt.py +++ b/reusables/dt.py @@ -1,19 +1,19 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License from __future__ import absolute_import import datetime import re from reusables.namespace import Namespace -__all__ = ['dt_exps', 'datetime_regex', 'now', 'datetime_format', - 'datetime_from_iso', 'dtf', 'dtiso'] +__all__ = ["dt_exps", "datetime_regex", "now", "datetime_format", "datetime_from_iso", "dtf", "dtiso"] -dt_exps = {"datetime": { +dt_exps = { + "datetime": { "format": { "%I": re.compile(r"{(?:12)?-?hours?}"), "%H": re.compile(r"{24-?hours?}"), @@ -38,21 +38,20 @@ "%c": re.compile(r"{date-?time}"), "%z": re.compile(r"{(?:utc)?-?offset}"), "%p": re.compile(r"{periods?}"), - "%Y-%m-%dT%H:%M:%S": re.compile(r"{iso-?(?:format)?}") + "%Y-%m-%dT%H:%M:%S": re.compile(r"{iso-?(?:format)?}"), }, - "date": re.compile(r"((?:[\d]{2}|[\d]{4})[\- _\\/]?[\d]{2}[\- _\\/]?" - r"\n[\d]{2})"), + "date": re.compile(r"((?:[\d]{2}|[\d]{4})[\- _\\/]?[\d]{2}[\- _\\/]?" r"\n[\d]{2})"), "time": re.compile(r"([\d]{2}:[\d]{2}(?:\.[\d]{6})?)"), - "datetime": re.compile(r"((?:[\d]{2}|[\d]{4})[\- _\\/]?[\d]{2}" - r"[\- _\\/]?[\d]{2}T[\d]{2}:[\d]{2}" - r"(?:\.[\d]{6})?)") + "datetime": re.compile( + r"((?:[\d]{2}|[\d]{4})[\- _\\/]?[\d]{2}" r"[\- _\\/]?[\d]{2}T[\d]{2}:[\d]{2}" r"(?:\.[\d]{6})?)" + ), } } datetime_regex = Namespace(**dt_exps) -def datetime_format(desired_format, datetime_instance=None, *args, **kwargs): +def datetime_format(desired_format, datetime_instance=None, *args, **kwargs): """ Replaces format style phrases (listed in the dt_exps dictionary) with this datetime instance's information. @@ -115,5 +114,6 @@ def now(utc=False, tz=None): """ return datetime.datetime.utcnow() if utc else datetime.datetime.now(tz=tz) + dtf = datetime_format dtiso = datetime_from_iso diff --git a/reusables/file_operations.py b/reusables/file_operations.py index fd4d027..9a1989f 100644 --- a/reusables/file_operations.py +++ b/reusables/file_operations.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License import os import zipfile import tarfile @@ -14,6 +14,7 @@ import glob import shutil from collections import defaultdict + try: import ConfigParser as ConfigParser except ImportError: @@ -22,19 +23,37 @@ from reusables.namespace import * from reusables.shared_variables import * -__all__ = ['load_json', 'list_to_csv', 'save_json', 'csv_to_list', - 'extract', 'archive', 'config_dict', 'config_namespace', - 'os_tree', 'check_filename', 'count_files', - 'directory_duplicates', 'dup_finder', 'file_hash', 'find_files', - 'find_files_list', 'join_here', 'join_paths', - 'remove_empty_directories', 'remove_empty_files', - 'safe_filename', 'safe_path', 'touch', 'sync_dirs'] - -logger = logging.getLogger('reusables') - - -def extract(archive_file, path=".", delete_on_success=False, - enable_rar=False): +__all__ = [ + "load_json", + "list_to_csv", + "save_json", + "csv_to_list", + "extract", + "archive", + "config_dict", + "config_namespace", + "os_tree", + "check_filename", + "count_files", + "directory_duplicates", + "dup_finder", + "file_hash", + "find_files", + "find_files_list", + "join_here", + "join_paths", + "remove_empty_directories", + "remove_empty_files", + "safe_filename", + "safe_path", + "touch", + "sync_dirs", +] + +logger = logging.getLogger("reusables") + + +def extract(archive_file, path=".", delete_on_success=False, enable_rar=False): """ Automatically detect archive type and extract all files to specified path. @@ -71,9 +90,9 @@ def extract(archive_file, path=".", delete_on_success=False, arch = tarfile.open(archive_file) elif enable_rar: import rarfile + if rarfile.is_rarfile(archive_file): - logger.debug("File {0} detected as " - "a rar file".format(archive_file)) + logger.debug("File {0} detected as " "a rar file".format(archive_file)) arch = rarfile.RarFile(archive_file) if not arch: @@ -93,9 +112,17 @@ def extract(archive_file, path=".", delete_on_success=False, return os.path.abspath(path) -def archive(files_to_archive, name="archive.zip", archive_type=None, - overwrite=False, store=False, depth=None, err_non_exist=True, - allow_zip_64=True, **tarfile_kwargs): +def archive( + files_to_archive, + name="archive.zip", + archive_type=None, + overwrite=False, + store=False, + depth=None, + err_non_exist=True, + allow_zip_64=True, + **tarfile_kwargs, +): """ Archive a list of files (or files inside a folder), can chose between - zip @@ -133,14 +160,12 @@ def archive(files_to_archive, name="archive.zip", archive_type=None, elif name.lower().endswith("tar"): archive_type = "tar" else: - err_msg = ("Could not determine archive " - "type based off {0}".format(name)) + err_msg = "Could not determine archive " "type based off {0}".format(name) logger.error(err_msg) raise ValueError(err_msg) logger.debug("{0} file detected for {1}".format(archive_type, name)) elif archive_type not in ("tar", "gz", "bz2", "zip"): - err_msg = ("archive_type must be zip, gz, bz2," - " or gz, was {0}".format(archive_type)) + err_msg = "archive_type must be zip, gz, bz2," " or gz, was {0}".format(archive_type) logger.error(err_msg) raise ValueError(err_msg) @@ -150,14 +175,13 @@ def archive(files_to_archive, name="archive.zip", archive_type=None, raise OSError(err_msg) if archive_type == "zip": - arch = zipfile.ZipFile(name, 'w', - zipfile.ZIP_STORED if store else - zipfile.ZIP_DEFLATED, - allowZip64=allow_zip_64) + arch = zipfile.ZipFile( + name, "w", zipfile.ZIP_STORED if store else zipfile.ZIP_DEFLATED, allowZip64=allow_zip_64 + ) write = arch.write elif archive_type in ("tar", "gz", "bz2"): mode = archive_type if archive_type != "tar" else "" - arch = tarfile.open(name, 'w:{0}'.format(mode), **tarfile_kwargs) + arch = tarfile.open(name, "w:{0}".format(mode), **tarfile_kwargs) write = arch.add else: raise ValueError("archive_type must be zip, gz, bz2, or gz") @@ -169,8 +193,7 @@ def archive(files_to_archive, name="archive.zip", archive_type=None, raise OSError("File {0} does not exist".format(file_path)) write(file_path) elif os.path.isdir(file_path): - for nf in find_files(file_path, abspath=False, - depth=depth, disable_pathlib=True): + for nf in find_files(file_path, abspath=False, depth=depth, disable_pathlib=True): write(nf) except (Exception, KeyboardInterrupt) as err: logger.exception("Could not archive {0}".format(files_to_archive)) @@ -211,12 +234,12 @@ def list_to_csv(my_list, csv_file): :param csv_file: File to save data to """ if PY3: - csv_handler = open(csv_file, 'w', newline='') + csv_handler = open(csv_file, "w", newline="") else: - csv_handler = open(csv_file, 'wb') + csv_handler = open(csv_file, "wb") try: - writer = csv.writer(csv_handler, delimiter=',', quoting=csv.QUOTE_ALL) + writer = csv.writer(csv_handler, delimiter=",", quoting=csv.QUOTE_ALL) writer.writerows(my_list) finally: csv_handler.close() @@ -237,7 +260,7 @@ def csv_to_list(csv_file): :param csv_file: Path to CSV file as str :return: list """ - with open(csv_file, 'r' if PY3 else 'rb') as f: + with open(csv_file, "r" if PY3 else "rb") as f: return list(csv.reader(f)) @@ -327,9 +350,13 @@ def config_dict(config_file=None, auto_find=False, verify=True, **cfg_options): cfg_files.extend(config_file) if auto_find: - cfg_files.extend(find_files_list( - current_root if isinstance(auto_find, bool) else auto_find, - ext=(".cfg", ".config", ".ini"), disable_pathlib=True)) + cfg_files.extend( + find_files_list( + current_root if isinstance(auto_find, bool) else auto_find, + ext=(".cfg", ".config", ".ini"), + disable_pathlib=True, + ) + ) logger.info("config files to be used: {0}".format(cfg_files)) @@ -338,12 +365,10 @@ def config_dict(config_file=None, auto_find=False, verify=True, **cfg_options): else: cfg_parser.read(cfg_files) - return dict((section, dict(cfg_parser.items(section))) - for section in cfg_parser.sections()) + return dict((section, dict(cfg_parser.items(section))) for section in cfg_parser.sections()) -def config_namespace(config_file=None, auto_find=False, - verify=True, **cfg_options): +def config_namespace(config_file=None, auto_find=False, verify=True, **cfg_options): """ Return configuration options as a Namespace. @@ -360,8 +385,7 @@ def config_namespace(config_file=None, auto_find=False, :param cfg_options: options to pass to the parser :return: Namespace of the config files """ - return ConfigNamespace(**config_dict(config_file, auto_find, - verify, **cfg_options)) + return ConfigNamespace(**config_dict(config_file, auto_find, verify, **cfg_options)) def _walk(directory, enable_scandir=False, **kwargs): @@ -376,6 +400,7 @@ def _walk(directory, enable_scandir=False, **kwargs): walk = os.walk if python_version < (3, 5) and enable_scandir: import scandir + walk = scandir.walk return walk(directory, **kwargs) @@ -405,12 +430,11 @@ def os_tree(directory, enable_scandir=False): full_list = [] for root, dirs, files in _walk(directory, enable_scandir=enable_scandir): - full_list.extend([os.path.join(root, d).lstrip(directory) + os.sep - for d in dirs]) + full_list.extend([os.path.join(root, d).lstrip(directory) + os.sep for d in dirs]) tree = {os.path.basename(directory): {}} for item in full_list: separated = item.split(os.sep) - is_dir = separated[-1:] == [''] + is_dir = separated[-1:] == [""] if is_dir: separated = separated[:-1] parent = tree[os.path.basename(directory)] @@ -462,9 +486,17 @@ def count_files(*args, **kwargs): return sum(1 for _ in find_files(*args, **kwargs)) -def find_files(directory=".", ext=None, name=None, - match_case=False, disable_glob=False, depth=None, - abspath=False, enable_scandir=False, disable_pathlib=False): +def find_files( + directory=".", + ext=None, + name=None, + match_case=False, + disable_glob=False, + depth=None, + abspath=False, + enable_scandir=False, + disable_pathlib=False, +): """ Walk through a file directory and return an iterator of files that match requirements. Will autodetect if name has glob as magic @@ -505,10 +537,12 @@ def find_files(directory=".", ext=None, name=None, :param disable_pathlib: only return string, not path objects :return: generator of all files in the specified directory """ + def pathed(path): if python_version < (3, 4) or disable_pathlib: return path import pathlib + return pathlib.Path(path) if ext or not name: @@ -530,8 +564,7 @@ def pathed(path): if not disable_glob: if match_case: - raise ValueError("Cannot use glob and match case, please " - "either disable glob or not set match_case") + raise ValueError("Cannot use glob and match case, please " "either disable glob or not set match_case") glob_generator = glob.iglob(os.path.join(root, name)) for item in glob_generator: yield pathed(item) @@ -540,8 +573,7 @@ def pathed(path): for file_name in files: if ext: for end in ext: - if file_name.lower().endswith(end.lower() if not - match_case else end): + if file_name.lower().endswith(end.lower() if not match_case else end): break else: continue @@ -553,8 +585,7 @@ def pathed(path): yield pathed(os.path.join(root, file_name)) -def remove_empty_directories(root_directory, dry_run=False, ignore_errors=True, - enable_scandir=False): +def remove_empty_directories(root_directory, dry_run=False, ignore_errors=True, enable_scandir=False): """ Remove all empty folders from a path. Returns list of empty directories. @@ -572,11 +603,8 @@ def listdir(directory): return list(_scandir.scandir(directory)) directory_list = [] - for root, directories, files in _walk(root_directory, - enable_scandir=enable_scandir, - topdown=False): - if (not directories and not files and os.path.exists(root) and - root != root_directory and os.path.isdir(root)): + for root, directories, files in _walk(root_directory, enable_scandir=enable_scandir, topdown=False): + if not directories and not files and os.path.exists(root) and root != root_directory and os.path.isdir(root): directory_list.append(root) if not dry_run: try: @@ -589,23 +617,20 @@ def listdir(directory): elif directories and not files: for directory in directories: directory = join_paths(root, directory, strict=True) - if (os.path.exists(directory) and os.path.isdir(directory) and - not listdir(directory)): + if os.path.exists(directory) and os.path.isdir(directory) and not listdir(directory): directory_list.append(directory) if not dry_run: try: os.rmdir(directory) except OSError as err: if ignore_errors: - logger.info("{0} could not be deleted".format( - directory)) + logger.info("{0} could not be deleted".format(directory)) else: raise err return directory_list -def remove_empty_files(root_directory, dry_run=False, ignore_errors=True, - enable_scandir=False): +def remove_empty_files(root_directory, dry_run=False, ignore_errors=True, enable_scandir=False): """ Remove all empty files from a path. Returns list of the empty files removed. @@ -616,8 +641,7 @@ def remove_empty_files(root_directory, dry_run=False, ignore_errors=True, :return: list of removed files """ file_list = [] - for root, directories, files in _walk(root_directory, - enable_scandir=enable_scandir): + for root, directories, files in _walk(root_directory, enable_scandir=enable_scandir): for file_name in files: file_path = join_paths(root, file_name, strict=True) if os.path.isfile(file_path) and not os.path.getsize(file_path): @@ -672,28 +696,26 @@ def dup_finder(file_path, directory=".", enable_scandir=False): for empty_file in remove_empty_files(directory, dry_run=True): yield empty_file else: - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: first_twenty = f.read(20) file_sha256 = file_hash(file_path, "sha256") - for root, directories, files in _walk(directory, - enable_scandir=enable_scandir): + for root, directories, files in _walk(directory, enable_scandir=enable_scandir): for each_file in files: test_file = os.path.join(root, each_file) if os.path.getsize(test_file) == size: try: - with open(test_file, 'rb') as f: + with open(test_file, "rb") as f: test_first_twenty = f.read(20) except OSError: - logger.warning("Could not open file to compare - " - "{0}".format(test_file)) + logger.warning("Could not open file to compare - " "{0}".format(test_file)) else: if first_twenty == test_first_twenty: if file_hash(test_file, "sha256") == file_sha256: yield os.path.abspath(test_file) -def directory_duplicates(directory, hash_type='md5', **kwargs): +def directory_duplicates(directory, hash_type="md5", **kwargs): """ Find all duplicates in a directory. Will return a list, in that list are lists of duplicate files. @@ -733,7 +755,7 @@ def touch(path): :param path: path to file to 'touch' """ - with open(path, 'a'): + with open(path, "a"): os.utime(path, None) @@ -764,7 +786,7 @@ def join_paths(*paths, **kwargs): for next_path in paths[1:]: path = os.path.join(path, next_path.lstrip("\\").lstrip("/").strip()) path.rstrip(os.sep) - return path if not kwargs.get('safe') else safe_path(path) + return path if not kwargs.get("safe") else safe_path(path) def join_here(*paths, **kwargs): @@ -783,10 +805,9 @@ def join_here(*paths, **kwargs): """ path = os.path.abspath(".") for next_path in paths: - next_path = next_path.lstrip("\\").lstrip("/").strip() if not \ - kwargs.get('strict') else next_path + next_path = next_path.lstrip("\\").lstrip("/").strip() if not kwargs.get("strict") else next_path path = os.path.abspath(os.path.join(path, next_path)) - return path if not kwargs.get('safe') else safe_path(path) + return path if not kwargs.get("safe") else safe_path(path) def check_filename(filename): @@ -821,8 +842,7 @@ def safe_filename(filename, replacement="_"): return filename safe_name = "" for char in filename: - safe_name += char if regex.path.linux.filename.search(char) \ - else replacement + safe_name += char if regex.path.linux.filename.search(char) else replacement return safe_name @@ -854,19 +874,17 @@ def safe_path(path, replacement="_"): else: for char in dirname: safe_dirname += char if regexp.search(char) else replacement - sanitized_path = os.path.normpath("{path}{sep}{filename}".format( - path=safe_dirname, - sep=os.sep if not safe_dirname.endswith(os.sep) else "", - filename=filename)) - if (not filename and - path.endswith(os.sep) and - not sanitized_path.endswith(os.sep)): + sanitized_path = os.path.normpath( + "{path}{sep}{filename}".format( + path=safe_dirname, sep=os.sep if not safe_dirname.endswith(os.sep) else "", filename=filename + ) + ) + if not filename and path.endswith(os.sep) and not sanitized_path.endswith(os.sep): sanitized_path += os.sep return sanitized_path -def sync_dirs(dir1, dir2, checksums=True, overwrite=False, - only_log_errors=True): +def sync_dirs(dir1, dir2, checksums=True, overwrite=False, only_log_errors=True): """ Make sure all files in directory 1 exist in directory 2. @@ -877,6 +895,7 @@ def sync_dirs(dir1, dir2, checksums=True, overwrite=False, :param only_log_errors: Do not raise copy errors, only log them :return: None """ + def cp(f1, f2): try: shutil.copy(f1, f2) @@ -887,32 +906,22 @@ def cp(f1, f2): raise for file in find_files(dir1, disable_pathlib=True): - path_two = os.path.join(dir2, file[len(dir1)+1:]) + path_two = os.path.join(dir2, file[len(dir1) + 1 :]) try: os.makedirs(os.path.dirname(path_two)) except OSError: pass # Because exists_ok doesn't exist in 2.x if os.path.exists(path_two): if os.path.getsize(file) != os.path.getsize(path_two): - logger.info("File sizes do not match: " - "{} - {}".format(file, path_two)) + logger.info("File sizes do not match: " "{} - {}".format(file, path_two)) if overwrite: logger.info("Overwriting {}".format(path_two)) cp(file, path_two) elif checksums and (file_hash(file) != file_hash(path_two)): - logger.warning("Files do not match: " - "{} - {}".format(file, path_two)) + logger.warning("Files do not match: " "{} - {}".format(file, path_two)) if overwrite: logger.info("Overwriting {}".format(file, path_two)) cp(file, path_two) else: logger.info("Copying {} to {}".format(file, path_two)) cp(file, path_two) - - - - - - - - diff --git a/reusables/log.py b/reusables/log.py index 5407540..c8788b0 100644 --- a/reusables/log.py +++ b/reusables/log.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License """ Logging helper functions and common log formats. """ @@ -11,31 +11,48 @@ import warnings import logging import sys -from logging.handlers import (RotatingFileHandler, - TimedRotatingFileHandler) +import io +import contextlib +from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler from reusables.namespace import Namespace from reusables.shared_variables import sizes - -__all__ = ['log_formats', 'get_logger', 'setup_logger', 'get_registered_loggers', - 'get_file_handler', 'get_stream_handler', 'add_file_handler', - 'add_stream_handler', 'add_rotating_file_handler', - 'add_timed_rotating_file_handler', 'change_logger_levels', - 'remove_all_handlers', 'remove_file_handlers', - 'remove_stream_handlers', 'setup_logger'] - -log_formats = Namespace({ - 'common': '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - 'level_first': '%(levelname)s - %(name)s - %(asctime)s - %(message)s', - 'threaded': '%(relativeCreated)d %(threadName)s : %(message)s', - 'easy_read': '%(asctime)s - %(name)-12s %(levelname)-8s %(message)s', - 'easy_thread': '%(relativeCreated)8d %(threadName)s : %(name)-12s ' - '%(levelname)-8s %(message)s', - 'detailed': '%(asctime)s : %(relativeCreated)5d %(threadName)s : %(name)s ' - '%(levelname)s %(message)s' -}) +from reusables.wrappers import log_it, log_exception + +__all__ = [ + "log_formats", + "get_logger", + "setup_logger", + "get_registered_loggers", + "get_file_handler", + "get_stream_handler", + "add_file_handler", + "add_stream_handler", + "add_rotating_file_handler", + "add_timed_rotating_file_handler", + "change_logger_levels", + "remove_all_handlers", + "remove_file_handlers", + "remove_stream_handlers", + "setup_logger", + "log_it", + "log_exception", + "LoggerIOWrapper", +] + +log_formats = Namespace( + { + "common": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + "level_first": "%(levelname)s - %(name)s - %(asctime)s - %(message)s", + "threaded": "%(relativeCreated)d %(threadName)s : %(message)s", + "easy_read": "%(asctime)s - %(name)-12s %(levelname)-8s %(message)s", + "easy_thread": "%(relativeCreated)8d %(threadName)s : %(name)-12s " "%(levelname)-8s %(message)s", + "detailed": "%(asctime)s : %(relativeCreated)5d %(threadName)s : %(name)s " "%(levelname)s %(message)s", + } +) if sys.version_info < (2, 7): + class NullHandler(logging.Handler): def emit(self, record): pass @@ -45,13 +62,11 @@ def emit(self, record): def get_logger(*args, **kwargs): """ Depreciated, use setup_logger""" - warnings.warn("get_logger is changing name to setup_logger", - DeprecationWarning) + warnings.warn("get_logger is changing name to setup_logger", DeprecationWarning) return setup_logger(*args, **kwargs) -def get_stream_handler(stream=sys.stderr, level=logging.INFO, - log_format=log_formats.easy_read): +def get_stream_handler(stream=sys.stderr, level=logging.INFO, log_format=log_formats.easy_read): """ Returns a set up stream handler to add to a logger. @@ -66,10 +81,13 @@ def get_stream_handler(stream=sys.stderr, level=logging.INFO, return sh -def get_file_handler(file_path="out.log", level=logging.INFO, - log_format=log_formats.easy_read, - handler=logging.FileHandler, - **handler_kwargs): +def get_file_handler( + file_path="out.log", + level=logging.INFO, + log_format=log_formats.easy_read, + handler=logging.FileHandler, + **handler_kwargs, +): """ Set up a file handler to add to a logger. @@ -86,9 +104,14 @@ def get_file_handler(file_path="out.log", level=logging.INFO, return fh -def setup_logger(module_name=None, level=logging.INFO, stream=sys.stderr, - file_path=None, log_format=log_formats.easy_read, - suppress_warning=True): +def setup_logger( + module_name=None, + level=logging.INFO, + stream=sys.stderr, + file_path=None, + log_format=log_formats.easy_read, + suppress_warning=True, +): """ Grabs the specified logger and adds wanted handlers to it. Will default to adding a stream handler. @@ -106,7 +129,7 @@ def setup_logger(module_name=None, level=logging.INFO, stream=sys.stderr, if stream: new_logger.addHandler(get_stream_handler(stream, level, log_format)) elif not file_path and suppress_warning and not new_logger.handlers: - new_logger.addHandler(logging.NullHandler()) + new_logger.addHandler(logging.NullHandler()) if file_path: new_logger.addHandler(get_file_handler(file_path, level, log_format)) @@ -115,8 +138,7 @@ def setup_logger(module_name=None, level=logging.INFO, stream=sys.stderr, return new_logger -def add_stream_handler(logger=None, stream=sys.stderr, level=logging.INFO, - log_format=log_formats.easy_read): +def add_stream_handler(logger=None, stream=sys.stderr, level=logging.INFO, log_format=log_formats.easy_read): """ Addes a newly created stream handler to the specified logger @@ -131,8 +153,7 @@ def add_stream_handler(logger=None, stream=sys.stderr, level=logging.INFO, logger.addHandler(get_stream_handler(stream, level, log_format)) -def add_file_handler(logger=None, file_path="out.log", level=logging.INFO, - log_format=log_formats.easy_read): +def add_file_handler(logger=None, file_path="out.log", level=logging.INFO, log_format=log_formats.easy_read): """ Addes a newly created file handler to the specified logger @@ -147,11 +168,15 @@ def add_file_handler(logger=None, file_path="out.log", level=logging.INFO, logger.addHandler(get_file_handler(file_path, level, log_format)) -def add_rotating_file_handler(logger=None, file_path="out.log", - level=logging.INFO, - log_format=log_formats.easy_read, - max_bytes=10*sizes.mb, backup_count=5, - **handler_kwargs): +def add_rotating_file_handler( + logger=None, + file_path="out.log", + level=logging.INFO, + log_format=log_formats.easy_read, + max_bytes=10 * sizes.mb, + backup_count=5, + **handler_kwargs, +): """ Adds a rotating file handler to the specified logger. :param logger: logging name or object to modify, defaults to root logger @@ -165,18 +190,29 @@ def add_rotating_file_handler(logger=None, file_path="out.log", if not isinstance(logger, logging.Logger): logger = logging.getLogger(logger) - logger.addHandler(get_file_handler(file_path, level, log_format, - handler=RotatingFileHandler, - maxBytes=max_bytes, - backupCount=backup_count, - **handler_kwargs)) - - -def add_timed_rotating_file_handler(logger=None, file_path="out.log", - level=logging.INFO, - log_format=log_formats.easy_read, - when='w0', interval=1, backup_count=5, - **handler_kwargs): + logger.addHandler( + get_file_handler( + file_path, + level, + log_format, + handler=RotatingFileHandler, + maxBytes=max_bytes, + backupCount=backup_count, + **handler_kwargs, + ) + ) + + +def add_timed_rotating_file_handler( + logger=None, + file_path="out.log", + level=logging.INFO, + log_format=log_formats.easy_read, + when="w0", + interval=1, + backup_count=5, + **handler_kwargs, +): """ Adds a timed rotating file handler to the specified logger. Defaults to weekly rotation, with 5 backups. @@ -192,12 +228,18 @@ def add_timed_rotating_file_handler(logger=None, file_path="out.log", if not isinstance(logger, logging.Logger): logger = logging.getLogger(logger) - logger.addHandler(get_file_handler(file_path, level, log_format, - handler=TimedRotatingFileHandler, - when=when, - interval=interval, - backupCount=backup_count, - **handler_kwargs)) + logger.addHandler( + get_file_handler( + file_path, + level, + log_format, + handler=TimedRotatingFileHandler, + when=when, + interval=interval, + backupCount=backup_count, + **handler_kwargs, + ) + ) def remove_stream_handlers(logger=None): @@ -213,10 +255,11 @@ def remove_stream_handlers(logger=None): for handler in logger.handlers: # FileHandler is a subclass of StreamHandler so # 'if not a StreamHandler' does not work - if (isinstance(handler, logging.FileHandler) or - isinstance(handler, logging.NullHandler) or - (isinstance(handler, logging.Handler) and not - isinstance(handler, logging.StreamHandler))): + if ( + isinstance(handler, logging.FileHandler) + or isinstance(handler, logging.NullHandler) + or (isinstance(handler, logging.Handler) and not isinstance(handler, logging.StreamHandler)) + ): new_handlers.append(handler) logger.handlers = new_handlers @@ -278,7 +321,44 @@ def get_registered_loggers(hide_children=False, hide_reusables=False): :return: list of logger names """ - return [logger for logger in logging.Logger.manager.loggerDict.keys() - if not (hide_reusables and "reusables" in logger) - and not (hide_children and "." in logger)] + return [ + logger + for logger in logging.Logger.manager.loggerDict.keys() + if not (hide_reusables and "reusables" in logger) and not (hide_children and "." in logger) + ] + + +class LoggerIOWrapper(io.IOBase): + """ + A wrapper around logging.Logger in order to set the logger as a standard stream + such as redirecting the sys.stdout input like so + sys.stdout = LoggerIOWrapper(logging.getLogger(__name__)) + + :param logger: logger to log the messages to. + :type logger: logging.Logger + """ + + def __init__(self, logger): + super().__init__() + self.logger = logger + + def close(self): + handlers = (h for h in self.logger.handlers if isinstance(h, logging.FileHandler)) + if handlers: + for h in handlers: + h.stream.close() + + def fileno(self): + for handle in self.logger.handlers: + with contextlib.suppress(OSError): + return handle.stream.fileno() + raise OSError() + + def write(self, message, *args, level=logging.DEBUG, exc_info=False): + self.logger.log(level, message, *args, exc_info=exc_info) + + def writeable(self): + return True + def readable(self): + return False diff --git a/reusables/namespace.py b/reusables/namespace.py index bfd1d77..9896a4a 100644 --- a/reusables/namespace.py +++ b/reusables/namespace.py @@ -1,14 +1,15 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License """ Improved dictionary management. Inspired by javascript style referencing, as it's one of the few things they got right. """ import sys + try: from collections.abc import Mapping, Iterable except ImportError: @@ -18,7 +19,7 @@ if sys.version_info >= (3, 0): basestring = str -__all__ = ['Namespace', 'ConfigNamespace', 'ProtectedDict', 'ns', 'cns'] +__all__ = ["Namespace", "ConfigNamespace", "ProtectedDict", "ns", "cns"] def _recursive_create(self, iterable): @@ -39,7 +40,7 @@ class Namespace(dict): - namespace['spam'].eggs """ - _protected_keys = dir({}) + ['to_dict', 'tree_view'] + _protected_keys = dir({}) + ["to_dict", "tree_view"] def __init__(self, *args, **kwargs): if len(args) == 1: @@ -52,8 +53,7 @@ def __init__(self, *args, **kwargs): else: raise ValueError("First argument must be mapping or iterable") elif args: - raise TypeError("Namespace expected at most 1 argument, " - "got {0}".format(len(args))) + raise TypeError("Namespace expected at most 1 argument, " "got {0}".format(len(args))) _recursive_create(self, kwargs.items()) def __contains__(self, item): @@ -133,9 +133,12 @@ def tree_view(dictionary, level=0, sep="| "): """ View a dictionary as a tree. """ - return "".join(["{0}{1}\n{2}".format(sep * level, k, - tree_view(v, level + 1, sep=sep) if isinstance(v, dict) - else "") for k, v in dictionary.items()]) + return "".join( + [ + "{0}{1}\n{2}".format(sep * level, k, tree_view(v, level + 1, sep=sep) if isinstance(v, dict) else "") + for k, v in dictionary.items() + ] + ) class ConfigNamespace(Namespace): @@ -152,9 +155,17 @@ class ConfigNamespace(Namespace): """ - _protected_keys = dir({}) + ['to_dict', 'tree_view', - 'bool', 'int', 'float', 'list', 'getboolean', - 'getfloat', 'getint'] + _protected_keys = dir({}) + [ + "to_dict", + "tree_view", + "bool", + "int", + "float", + "list", + "getboolean", + "getfloat", + "getint", + ] def __getattr__(self, item): """Config file keys are stored in lower case, be a little more @@ -181,8 +192,7 @@ def bool(self, item, default=None): if isinstance(item, (bool, int)): return bool(item) - if (isinstance(item, str) and - item.lower() in ('n', 'no', 'false', 'f', '0')): + if isinstance(item, str) and item.lower() in ("n", "no", "false", "f", "0"): return False return True if item else False @@ -299,5 +309,6 @@ def __hash__(self): hashed ^= hash((key, value)) return hashed + ns = Namespace cns = ConfigNamespace diff --git a/reusables/other.py b/reusables/other.py index ef70b66..82e5422 100644 --- a/reusables/other.py +++ b/reusables/other.py @@ -1,12 +1,12 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License from contextlib import contextmanager -__all__ = ['ignored'] +__all__ = ["ignored"] @contextmanager @@ -16,3 +16,14 @@ def ignored(*exceptions): yield except exceptions: pass + + +class Singleton(type): + """Singleton design pattern metaclass. Ensures only one instance of an object exists at any time.""" + + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] diff --git a/reusables/process_helpers.py b/reusables/process_helpers.py index df04351..d549cb6 100644 --- a/reusables/process_helpers.py +++ b/reusables/process_helpers.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License import os import sys import subprocess @@ -12,11 +12,12 @@ from reusables.shared_variables import * -__all__ = ['run', 'run_in_pool'] +__all__ = ["run", "run_in_pool"] -def run(command, input=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - timeout=None, copy_local_env=False, **kwargs): +def run( + command, input=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=None, copy_local_env=False, **kwargs +): """ Cross platform compatible subprocess with CompletedProcess return. @@ -49,14 +50,12 @@ def run(command, input=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, if copy_local_env: # Copy local env first and overwrite with anything manually specified env = os.environ.copy() - env.update(kwargs.get('env', {})) + env.update(kwargs.get("env", {})) else: - env = kwargs.get('env') + env = kwargs.get("env") if sys.version_info >= (3, 5): - return subprocess.run(command, input=input, stdout=stdout, - stderr=stderr, timeout=timeout, env=env, - **kwargs) + return subprocess.run(command, input=input, stdout=stdout, stderr=stderr, timeout=timeout, env=env, **kwargs) # Created here instead of root level as it should never need to be # manually created or referenced @@ -70,24 +69,21 @@ def __init__(self, args, returncode, stdout=None, stderr=None): self.stderr = stderr def __repr__(self): - args = ['args={0!r}'.format(self.args), - 'returncode={0!r}'.format(self.returncode), - 'stdout={0!r}'.format(self.stdout) if self.stdout else '', - 'stderr={0!r}'.format(self.stderr) if self.stderr else ''] - return "{0}({1})".format(type(self).__name__, - ', '.join(filter(None, args))) + args = [ + "args={0!r}".format(self.args), + "returncode={0!r}".format(self.returncode), + "stdout={0!r}".format(self.stdout) if self.stdout else "", + "stderr={0!r}".format(self.stderr) if self.stderr else "", + ] + return "{0}({1})".format(type(self).__name__, ", ".join(filter(None, args))) def check_returncode(self): if self.returncode: if python_version < (2, 7): - raise subprocess.CalledProcessError(self.returncode, - self.args) - raise subprocess.CalledProcessError(self.returncode, - self.args, - self.stdout) - - proc = subprocess.Popen(command, stdout=stdout, stderr=stderr, - env=env, **kwargs) + raise subprocess.CalledProcessError(self.returncode, self.args) + raise subprocess.CalledProcessError(self.returncode, self.args, self.stdout) + + proc = subprocess.Popen(command, stdout=stdout, stderr=stderr, env=env, **kwargs) if PY3: out, err = proc.communicate(input=input, timeout=timeout) else: @@ -97,8 +93,7 @@ def check_returncode(self): return CompletedProcess(command, proc.returncode, out, err) -def run_in_pool(target, iterable, threaded=True, processes=4, - asynchronous=False, target_kwargs=None): +def run_in_pool(target, iterable, threaded=True, processes=4, asynchronous=False, target_kwargs=None): """ Run a set of iterables to a function in a Threaded or MP Pool. .. code: python @@ -125,8 +120,7 @@ def func(a): p = my_pool(processes) try: - results = (p.map_async(target, iterable) if asynchronous - else p.map(target, iterable)) + results = p.map_async(target, iterable) if asynchronous else p.map(target, iterable) finally: p.close() p.join() diff --git a/reusables/sanitizers.py b/reusables/sanitizers.py index 2ed8687..3d3bcb5 100644 --- a/reusables/sanitizers.py +++ b/reusables/sanitizers.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- try: from collections.abc import Iterable, Callable except ImportError: @@ -21,8 +22,9 @@ def _get_input(prompt): return input(prompt) -def sanitized_input(message="", cast_as=None, number_of_retries=-1, - error_message="", valid_input=(), raise_on_invalid=False): +def sanitized_input( + message="", cast_as=None, number_of_retries=-1, error_message="", valid_input=(), raise_on_invalid=False +): """ Clean up and cast user input. @@ -62,14 +64,13 @@ def sanitized_input(message="", cast_as=None, number_of_retries=-1, retry_count = 0 cast_as = cast_as if cast_as is not None else str - cast_objects = list(cast_as) if isinstance(cast_as, Iterable) else (cast_as, ) + cast_objects = list(cast_as) if isinstance(cast_as, Iterable) else (cast_as,) for cast_obj in cast_objects: if not isinstance(cast_obj, Callable): - raise ValueError("ValueError: argument 'cast_as'" - "cannot be of type '{}'".format(type(cast_as))) + raise ValueError("ValueError: argument 'cast_as'" "cannot be of type '{}'".format(type(cast_as))) - if not hasattr(valid_input, '__iter__'): - valid_input = (valid_input, ) + if not hasattr(valid_input, "__iter__"): + valid_input = (valid_input,) while retry_count < number_of_retries or number_of_retries == -1: try: @@ -77,8 +78,9 @@ def sanitized_input(message="", cast_as=None, number_of_retries=-1, for cast_obj in reversed(cast_objects): return_value = cast_obj(return_value) if valid_input and return_value not in valid_input: - raise InvalidInputError("InvalidInputError: input invalid" - "in function 'sanitized_input' of {}".format(__name__)) + raise InvalidInputError( + "InvalidInputError: input invalid" "in function 'sanitized_input' of {}".format(__name__) + ) return return_value except (InvalidInputError, ValueError) as err: if raise_on_invalid and type(err).__name__ == "InvalidInputError": @@ -86,5 +88,6 @@ def sanitized_input(message="", cast_as=None, number_of_retries=-1, print(error_message.format(error=str(err)) if error_message else repr(err)) retry_count += 1 continue - raise RetryCountExceededError("RetryCountExceededError : count exceeded in" - "function 'sanitized_input' of {}".format(__name__)) + raise RetryCountExceededError( + "RetryCountExceededError : count exceeded in" "function 'sanitized_input' of {}".format(__name__) + ) diff --git a/reusables/shared_variables.py b/reusables/shared_variables.py index 87aafa5..8f5b857 100644 --- a/reusables/shared_variables.py +++ b/reusables/shared_variables.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License from __future__ import absolute_import import re as _re import os as _os @@ -27,62 +27,142 @@ reg_exps = { "path": { "windows": { - "valid": _re.compile(r'^(?:[a-zA-Z]:\\|\\\\?|\\\\\?\\|\\\\\.\\)?' - r'(?:(?!(CLOCK\$(\\|$)|(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]| )' - r'(?:\..*|(\\|$))|.*\.$))' - r'(?:(?:(?![><:/"\\\|\?\*])[\x20-\u10FFFF])+\\?))*$'), - "safe": _re.compile(r'^([a-zA-Z]:\\)?[\w\d _\-\\\(\)]+$'), - "filename": _re.compile(r'^((?![><:/"\\\|\?\*])[ -~])+$') + "valid": _re.compile( + r"^(?:[a-zA-Z]:\\|\\\\?|\\\\\?\\|\\\\\.\\)?" + r"(?:(?!(CLOCK\$(\\|$)|(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]| )" + r"(?:\..*|(\\|$))|.*\.$))" + r'(?:(?:(?![><:/"\\\|\?\*])[\x20-\u10FFFF])+\\?))*$' + ), + "safe": _re.compile(r"^([a-zA-Z]:\\)?[\w\d _\-\\\(\)]+$"), + "filename": _re.compile(r'^((?![><:/"\\\|\?\*])[ -~])+$'), }, "linux": { - "valid": _re.compile(r'^/?([\x01-\xFF]+/?)*$'), - "safe": _re.compile(r'^[\w\d\. _\-/\(\)]+$'), - "filename": _re.compile(r'^((?![><:/"\\\|\?\*])[ -~])+$') + "valid": _re.compile(r"^/?([\x01-\xFF]+/?)*$"), + "safe": _re.compile(r"^[\w\d\. _\-/\(\)]+$"), + "filename": _re.compile(r'^((?![><:/"\\\|\?\*])[ -~])+$'), }, "mac": { - "valid": _re.compile(r'^/?([\x01-\xFF]+/?)*$'), - "safe": _re.compile(r'^[\w\d\. _\-/\(\)]+$'), - "filename": _re.compile(r'^((?![><:/"\\\|\?\*])[ -~])+$') - } + "valid": _re.compile(r"^/?([\x01-\xFF]+/?)*$"), + "safe": _re.compile(r"^[\w\d\. _\-/\(\)]+$"), + "filename": _re.compile(r'^((?![><:/"\\\|\?\*])[ -~])+$'), + }, }, "python": { "module": { "attributes": _re.compile(r'__([a-z]+)__ *= *[\'"](.+)[\'"]'), - "imports": _re.compile(r'^ *\t*(?:import|from)[ ]+(?:(\w+)[, ]*)+'), - "functions": _re.compile(r'^ *\t*def +(\w+)\('), - "classes": _re.compile(r'^ *\t*class +(\w+)\('), - "docstrings": _re.compile(r'^ *\t*"""(.*)"""|\'\'\'(.*)\'\'\'') - } - }, - "pii": { - "phone_number": { - "us": _re.compile(r'((?:\(? ?\d{3} ?\)?[\. \-]?)?\d{3}' - r'[\. \-]?\d{4})') + "imports": _re.compile(r"^ *\t*(?:import|from)[ ]+(?:(\w+)[, ]*)+"), + "functions": _re.compile(r"^ *\t*def +(\w+)\("), + "classes": _re.compile(r"^ *\t*class +(\w+)\("), + "docstrings": _re.compile(r'^ *\t*"""(.*)"""|\'\'\'(.*)\'\'\''), } }, + "pii": {"phone_number": {"us": _re.compile(r"((?:\(? ?\d{3} ?\)?[\. \-]?)?\d{3}" r"[\. \-]?\d{4})")}}, } common_exts = { - "pictures": (".jpeg", ".jpg", ".png", ".gif", ".bmp", ".tif", ".tiff", - ".ico", ".mng", ".tga", ".psd", ".xcf", ".svg", ".icns"), - "video": (".mkv", ".avi", ".mp4", ".mov", ".flv", ".mpeg", ".mpg", ".3gp", - ".m4v", ".ogv", ".asf", ".m1v", ".m2v", ".mpe", ".ogv", ".wmv", - ".rm", ".qt", ".3g2", ".asf", ".vob"), - "music": (".mp3", ".ogg", ".wav", ".flac", ".aif", ".aiff", ".au", ".m4a", - ".wma", ".mp2", ".m4a", ".m4p", ".aac", ".ra", ".mid", ".midi", - ".mus", ".psf"), - "documents": (".doc", ".docx", ".pdf", ".xls", ".xlsx", ".ppt", ".pptx", - ".csv", ".epub", ".gdoc", ".odt", ".rtf", ".txt", ".info", - ".xps", ".gslides", ".gsheet", ".pages", ".msg", ".tex", - ".wpd", ".wps", ".csv"), - "archives": (".zip", ".rar", ".7z", ".tar.gz", ".tgz", ".gz", ".bzip", - ".bzip2", ".bz2", ".xz", ".lzma", ".bin", ".tar"), + "pictures": ( + ".jpeg", + ".jpg", + ".png", + ".gif", + ".bmp", + ".tif", + ".tiff", + ".ico", + ".mng", + ".tga", + ".psd", + ".xcf", + ".svg", + ".icns", + ), + "video": ( + ".mkv", + ".avi", + ".mp4", + ".mov", + ".flv", + ".mpeg", + ".mpg", + ".3gp", + ".m4v", + ".ogv", + ".asf", + ".m1v", + ".m2v", + ".mpe", + ".ogv", + ".wmv", + ".rm", + ".qt", + ".3g2", + ".asf", + ".vob", + ), + "music": ( + ".mp3", + ".ogg", + ".wav", + ".flac", + ".aif", + ".aiff", + ".au", + ".m4a", + ".wma", + ".mp2", + ".m4a", + ".m4p", + ".aac", + ".ra", + ".mid", + ".midi", + ".mus", + ".psf", + ), + "documents": ( + ".doc", + ".docx", + ".pdf", + ".xls", + ".xlsx", + ".ppt", + ".pptx", + ".csv", + ".epub", + ".gdoc", + ".odt", + ".rtf", + ".txt", + ".info", + ".xps", + ".gslides", + ".gsheet", + ".pages", + ".msg", + ".tex", + ".wpd", + ".wps", + ".csv", + ), + "archives": ( + ".zip", + ".rar", + ".7z", + ".tar.gz", + ".tgz", + ".gz", + ".bzip", + ".bzip2", + ".bz2", + ".xz", + ".lzma", + ".bin", + ".tar", + ), "cd_images": (".iso", ".nrg", ".img", ".mds", ".mdf", ".cue", ".daa"), "scripts": (".py", ".sh", ".bat"), "binaries": (".msi", ".exe"), - "markup": (".html", ".htm", ".xml", ".yaml", ".json", ".raml", ".xhtml", - ".kml"), - + "markup": (".html", ".htm", ".xml", ".yaml", ".json", ".raml", ".xhtml", ".kml"), } common_variables = { @@ -91,18 +171,20 @@ "md5": "d41d8cd98f00b204e9800998ecf8427e", "sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709", "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b\ -7852b855" +7852b855", }, }, } -sizes = Namespace({ - "kb": 1024, - "mb": 1024 * 1024, - "gb": 1024 * 1024 * 1024, - "tb": 1024 * 1024 * 1024 * 1024, - "pb": 1024 * 1024 * 1024 * 1024 * 1024 -}) +sizes = Namespace( + { + "kb": 1024, + "mb": 1024 * 1024, + "gb": 1024 * 1024 * 1024, + "tb": 1024 * 1024 * 1024 * 1024, + "pb": 1024 * 1024 * 1024 * 1024 * 1024, + } +) # Some may ask why make everything into namespaces, I ask why not regex = Namespace(reg_exps) diff --git a/reusables/string_manipulation.py b/reusables/string_manipulation.py index 9f9f219..45be344 100644 --- a/reusables/string_manipulation.py +++ b/reusables/string_manipulation.py @@ -1,24 +1,71 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License -_roman_dict = {'I': 1, 'IV': 4, 'V': 5, 'IX': 9, 'X': 10, 'XL': 40, 'L': 50, - 'XC': 90, 'C': 100, 'CD': 400, 'D': 500, 'CM': 900, 'M': 1000} - - -_numbers = {0: "zero", 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", - 6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten", - 11: "eleven", 12: "twelve", 13: "thirteen", 14: "fourteen", - 15: "fifteen", 16: "sixteen", 17: "seventeen", 18: "eighteen", - 19: "nineteen", 20: "twenty", 30: "thirty", 40: "forty", - 50: "fifty", 60: "sixty", 70: "seventy", 80: "eighty", - 90: "ninety"} - -_places = {1: "", 2: "thousand", 3: "million", 4: "billion", 5: "trillion", - 6: "quadrillion", 7: "quintillion", 8: "sextillion", - 9: "septillion", 10: "octillion", 11: "nonillion", 12: "decillion"} +# Copyright (c) 2014-2020 - Chris Griffith - MIT License +_roman_dict = { + "I": 1, + "IV": 4, + "V": 5, + "IX": 9, + "X": 10, + "XL": 40, + "L": 50, + "XC": 90, + "C": 100, + "CD": 400, + "D": 500, + "CM": 900, + "M": 1000, +} + + +_numbers = { + 0: "zero", + 1: "one", + 2: "two", + 3: "three", + 4: "four", + 5: "five", + 6: "six", + 7: "seven", + 8: "eight", + 9: "nine", + 10: "ten", + 11: "eleven", + 12: "twelve", + 13: "thirteen", + 14: "fourteen", + 15: "fifteen", + 16: "sixteen", + 17: "seventeen", + 18: "eighteen", + 19: "nineteen", + 20: "twenty", + 30: "thirty", + 40: "forty", + 50: "fifty", + 60: "sixty", + 70: "seventy", + 80: "eighty", + 90: "ninety", +} + +_places = { + 1: "", + 2: "thousand", + 3: "million", + 4: "billion", + 5: "trillion", + 6: "quadrillion", + 7: "quintillion", + 8: "sextillion", + 9: "septillion", + 10: "octillion", + 11: "nonillion", + 12: "decillion", +} def cut(string, characters=2, trailing="normal"): @@ -55,8 +102,7 @@ def cut(string, characters=2, trailing="normal"): :param trailing: "normal", "remove", "combine", or "error" :return: list of the cut string """ - split_str = [string[i:i + characters] for - i in range(0, len(string), characters)] + split_str = [string[i : i + characters] for i in range(0, len(string), characters)] if trailing != "normal" and len(split_str[-1]) != characters: if trailing.lower() == "remove": @@ -64,8 +110,7 @@ def cut(string, characters=2, trailing="normal"): if trailing.lower() == "combine" and len(split_str) >= 2: return split_str[:-2] + [split_str[-2] + split_str[-1]] if trailing.lower() == "error": - raise IndexError("String of length {0} not divisible by {1} to" - " cut".format(len(string), characters)) + raise IndexError("String of length {0} not divisible by {1} to" " cut".format(len(string), characters)) return split_str @@ -86,8 +131,7 @@ def int_to_roman(integer): raise ValueError("Input integer must be of type int") output = [] while integer > 0: - for r, i in sorted(_roman_dict.items(), - key=lambda x: x[1], reverse=True): + for r, i in sorted(_roman_dict.items(), key=lambda x: x[1], reverse=True): while integer >= i: output.append(r) integer -= i @@ -155,6 +199,7 @@ def int_to_words(number, european=False): set this parameter to True :return: The translated string """ + def ones(n): return "" if n == 0 else _numbers[n] @@ -179,8 +224,7 @@ def hundreds(n): def comma_separated(list_of_strings): if len(list_of_strings) > 1: - return "{0} ".format("" if len(list_of_strings) == 2 - else ",").join(list_of_strings) + return "{0} ".format("" if len(list_of_strings) == 2 else ",").join(list_of_strings) else: return list_of_strings[0] @@ -188,25 +232,22 @@ def while_loop(list_of_numbers, final_list): index = 0 group_set = int(len(list_of_numbers) / 3) while group_set != 0: - value = hundreds(list_of_numbers[index:index + 3]) + value = hundreds(list_of_numbers[index : index + 3]) if value: - final_list.append("{0} {1}".format(value, _places[group_set]) - if _places[group_set] else value) + final_list.append("{0} {1}".format(value, _places[group_set]) if _places[group_set] else value) group_set -= 1 index += 3 number_list = [] decimal_list = [] - decimal = '' + decimal = "" number = str(number) - group_delimiter, point_delimiter = (",", ".") \ - if not european else (".", ",") + group_delimiter, point_delimiter = (",", ".") if not european else (".", ",") if point_delimiter in number: decimal = number.split(point_delimiter)[1] - number = number.split(point_delimiter)[0].replace( - group_delimiter, "") + number = number.split(point_delimiter)[0].replace(group_delimiter, "") elif group_delimiter in number: number = number.replace(group_delimiter, "") @@ -233,15 +274,17 @@ def while_loop(list_of_numbers, final_list): while_loop(d, decimal_list) if decimal_list: - name = '' + name = "" if len(decimal) % 3 == 1: - name = 'ten' + name = "ten" elif len(decimal) % 3 == 2: - name = 'hundred' + name = "hundred" place = int((str(len(decimal) / 3).split(".")[0])) - number_list.append("and {0} {1}{2}{3}ths".format( - comma_separated(decimal_list), name, - "-" if name and _places[place+1] else "", _places[place+1])) + number_list.append( + "and {0} {1}{2}{3}ths".format( + comma_separated(decimal_list), name, "-" if name and _places[place + 1] else "", _places[place + 1] + ) + ) return comma_separated(number_list) diff --git a/reusables/tasker.py b/reusables/tasker.py index cc37107..631fa27 100644 --- a/reusables/tasker.py +++ b/reusables/tasker.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License try: import queue as queue except ImportError: @@ -16,7 +16,7 @@ from reusables.shared_variables import win_based -__all__ = ['Tasker'] +__all__ = ["Tasker"] class Tasker(object): @@ -45,11 +45,20 @@ class Tasker(object): :param run_until: datetime to run until """ - def __init__(self, tasks=(), max_tasks=4, task_timeout=None, - task_queue=None, result_queue=None, command_queue=None, - run_until=None, logger='reusables', **task_kwargs): + def __init__( + self, + tasks=(), + max_tasks=4, + task_timeout=None, + task_queue=None, + result_queue=None, + command_queue=None, + run_until=None, + logger="reusables", + **task_kwargs, + ): if logger: - self.log = logging.getLogger('reusables') + self.log = logging.getLogger("reusables") self.task_queue = task_queue or mp.Queue() if tasks: for task in tasks: @@ -64,7 +73,7 @@ def __init__(self, tasks=(), max_tasks=4, task_timeout=None, self.max_tasks = max_tasks self.timeout = task_timeout self.run_until = run_until - self._pause, self._end = mp.Value('b', False), mp.Value('b', False) + self._pause, self._end = mp.Value("b", False), mp.Value("b", False) self.background_process = None self.task_kwargs = task_kwargs @@ -86,15 +95,13 @@ def _update_tasks(self): for task_id in self.busy_tasks: if not self.current_tasks[task_id]: self.free_tasks.append(task_id) - elif not self.current_tasks[task_id]['proc'].is_alive(): + elif not self.current_tasks[task_id]["proc"].is_alive(): self.free_tasks.append(task_id) - elif self.timeout and (self.current_tasks[task_id]['start'] + - self.timeout) < time.time(): + elif self.timeout and (self.current_tasks[task_id]["start"] + self.timeout) < time.time(): try: - self.current_tasks[task_id]['proc'].terminate() + self.current_tasks[task_id]["proc"].terminate() except Exception as err: - self.log.exception("Error while terminating " - "task {} - {}".format(task_id, err)) + self.log.exception("Error while terminating " "task {} - {}".format(task_id, err)) self.free_tasks.append(task_id) else: still_busy.append(task_id) @@ -113,11 +120,11 @@ def _return_task(self, task_id): self.busy_tasks.remove(task_id) def _start_task(self, task_id, task): - self.current_tasks[task_id]['proc'] = mp.Process( - target=self.perform_task, args=(task, self.result_queue), - kwargs=self.task_kwargs) - self.current_tasks[task_id]['start_time'] = time.time() - self.current_tasks[task_id]['proc'].start() + self.current_tasks[task_id]["proc"] = mp.Process( + target=self.perform_task, args=(task, self.result_queue), kwargs=self.task_kwargs + ) + self.current_tasks[task_id]["start_time"] = time.time() + self.current_tasks[task_id]["proc"].start() def _reset_and_pause(self): self.current_tasks = {} @@ -172,7 +179,7 @@ def stop(self): pass for task_id, values in self.current_tasks.items(): try: - values['proc'].terminate() + values["proc"].terminate() except Exception: pass @@ -186,13 +193,14 @@ def unpuase(self): def get_state(self): """Get general information about the state of the class""" - return {"started": (True if self.background_process and - self.background_process.is_alive() else False), - "paused": self._pause.value, - "stopped": self._end.value, - "tasks": len(self.current_tasks), - "busy_tasks": len(self.busy_tasks), - "free_tasks": len(self.free_tasks)} + return { + "started": (True if self.background_process and self.background_process.is_alive() else False), + "paused": self._pause.value, + "stopped": self._end.value, + "tasks": len(self.current_tasks), + "busy_tasks": len(self.busy_tasks), + "free_tasks": len(self.free_tasks), + } def _check_command_queue(self): try: @@ -210,8 +218,7 @@ def _check_command_queue(self): try: new_size = int(cmd.split(" ")[-1]) except Exception as err: - self.log.warning("Received improperly formatted command tasking" - " '{0}' - {1}".format(cmd, err)) + self.log.warning("Received improperly formatted command tasking" " '{0}' - {1}".format(cmd, err)) else: self.change_task_size(new_size) else: @@ -243,14 +250,14 @@ def main_loop(self, stop_at_empty=False): if self._end.value: break if self._pause.value: - time.sleep(.5) + time.sleep(0.5) continue self.hook_post_command() self._update_tasks() task_id = self._free_task() if task_id: try: - task = self.task_queue.get(timeout=.1) + task = self.task_queue.get(timeout=0.1) except queue.Empty: if stop_at_empty: break @@ -261,8 +268,7 @@ def main_loop(self, stop_at_empty=False): try: self._start_task(task_id, task) except Exception as err: - self.log.exception("Could not start task {0} -" - " {1}".format(task_id, err)) + self.log.exception("Could not start task {0} -" " {1}".format(task_id, err)) else: self.hook_post_task() finally: @@ -271,7 +277,6 @@ def main_loop(self, stop_at_empty=False): def run(self): """Start the main loop as a background process. *nix only""" if win_based: - raise NotImplementedError("Please run main_loop, " - "backgrounding not supported on Windows") + raise NotImplementedError("Please run main_loop, " "backgrounding not supported on Windows") self.background_process = mp.Process(target=self.main_loop) self.background_process.start() diff --git a/reusables/web.py b/reusables/web.py index b5bc2d5..4e912c8 100644 --- a/reusables/web.py +++ b/reusables/web.py @@ -1,35 +1,34 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License from __future__ import absolute_import import os import logging import time import threading import socket + try: from urllib2 import urlopen except ImportError: from urllib.request import urlopen try: - from http.server import (HTTPServer as _server, - SimpleHTTPRequestHandler as _handler) + from http.server import HTTPServer as _server, SimpleHTTPRequestHandler as _handler except ImportError: from SimpleHTTPServer import SimpleHTTPRequestHandler as _handler from SocketServer import TCPServer as _server from reusables.file_operations import safe_filename -__all__ = ['download', 'ThreadedServer', 'url_to_ip', 'url_to_ips', 'ip_to_url'] +__all__ = ["download", "ThreadedServer", "url_to_ip", "url_to_ips", "ip_to_url"] -logger = logging.getLogger('reusables.web') +logger = logging.getLogger("reusables.web") -def download(url, save_to_file=True, save_dir=".", filename=None, - block_size=64000, overwrite=False, quiet=False): +def download(url, save_to_file=True, save_dir=".", filename=None, block_size=64000, overwrite=False, quiet=False): """ Download a given URL to either file or memory @@ -45,7 +44,7 @@ def download(url, save_to_file=True, save_dir=".", filename=None, if save_to_file: if not filename: - filename = safe_filename(url.split('/')[-1]) + filename = safe_filename(url.split("/")[-1]) if not filename: filename = "downloaded_at_{}.file".format(time.time()) save_location = os.path.abspath(os.path.join(save_dir, filename)) @@ -59,8 +58,7 @@ def download(url, save_to_file=True, save_dir=".", filename=None, request = urlopen(url) except ValueError as err: if not quiet and "unknown url type" in str(err): - logger.error("Please make sure URL is formatted correctly and" - " starts with http:// or other protocol") + logger.error("Please make sure URL is formatted correctly and" " starts with http:// or other protocol") raise err except Exception as err: if not quiet: @@ -74,12 +72,10 @@ def download(url, save_to_file=True, save_dir=".", filename=None, logger.debug("Could not determine file size - {0}".format(err)) file_size = "(unknown size)" else: - file_size = "({0:.1f} {1})".format(*(kb_size, "KB") if kb_size < 9999 - else (kb_size / 1024, "MB")) + file_size = "({0:.1f} {1})".format(*(kb_size, "KB") if kb_size < 9999 else (kb_size / 1024, "MB")) if not quiet: - logger.info("Downloading {0} {1} to {2}".format(url, file_size, - save_location)) + logger.info("Downloading {0} {1} to {2}".format(url, file_size, save_location)) if save_to_file: with open(save_location, "wb") as f: @@ -111,8 +107,8 @@ class ThreadedServer(object): :param server: Default is TCPServer (py2) or HTTPServer (py3) :param handler: Default is SimpleHTTPRequestHandler """ - def __init__(self, name="", port=8080, auto_start=True, server=_server, - handler=_handler): + + def __init__(self, name="", port=8080, auto_start=True, server=_server, handler=_handler): self.name = name self.port = port self._process = None @@ -134,8 +130,7 @@ def stop(self): self._process.join() -def url_to_ips(url, port=None, ipv6=False, connect_type=socket.SOCK_STREAM, - proto=socket.IPPROTO_TCP, flags=0): +def url_to_ips(url, port=None, ipv6=False, connect_type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP, flags=0): """ Provide a list of IP addresses, uses `socket.getaddrinfo` @@ -153,12 +148,9 @@ def url_to_ips(url, port=None, ipv6=False, connect_type=socket.SOCK_STREAM, :return: list of resolved IPs """ try: - results = socket.getaddrinfo(url, port, - (socket.AF_INET if not ipv6 - else socket.AF_INET6), - connect_type, - proto, - flags) + results = socket.getaddrinfo( + url, port, (socket.AF_INET if not ipv6 else socket.AF_INET6), connect_type, proto, flags + ) except socket.gaierror: logger.exception("Could not resolve hostname") return [] diff --git a/reusables/wrappers.py b/reusables/wrappers.py index ef7727d..f00aa38 100644 --- a/reusables/wrappers.py +++ b/reusables/wrappers.py @@ -1,15 +1,16 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- # # Part of the Reusables package. # -# Copyright (c) 2014-2019 - Chris Griffith - MIT License +# Copyright (c) 2014-2020 - Chris Griffith - MIT License from __future__ import absolute_import import time from threading import Lock from functools import wraps from collections import defaultdict import logging + try: import queue as _queue except ImportError: @@ -17,10 +18,18 @@ from reusables.shared_variables import python_version, ReusablesError -__all__ = ['unique', 'time_it', 'catch_it', 'log_exception', 'retry_it', - 'lock_it', 'queue_it'] - -logger = logging.getLogger("reusables.wrappers") +__all__ = [ + "unique", + "time_it", + "catch_it", + "log_exception", + "retry_it", + "lock_it", + "queue_it", + "log_it", +] + +reusables_logger = logging.getLogger("reusables.wrappers") g_lock = Lock() g_queue = _queue.Queue() unique_cache = defaultdict(list) @@ -34,8 +43,7 @@ def _add_args(message, *args, **kwargs): return message -def unique(max_retries=10, wait=0, alt_return="-no_alt_return-", - exception=Exception, error_text=None): +def unique(max_retries=10, wait=0, alt_return="-no_alt_return-", exception=Exception, error_text=None): """ Wrapper. Makes sure the function's return value has not been returned before or else it run with the same inputs again. @@ -64,11 +72,11 @@ def poor_uuid(): :param alt_return: if specified, an exception is not raised on failure, instead the provided value of any type of will be returned """ + def func_wrap(func): @wraps(func) def wrapper(*args, **kwargs): - msg = (error_text if error_text else - "No result was unique for function '{func}'") + msg = error_text if error_text else "No result was unique for function '{func}'" if not error_text: msg = _add_args(msg, *args, **kwargs) for i in range(max_retries): @@ -81,9 +89,10 @@ def wrapper(*args, **kwargs): else: if alt_return != "-no_alt_return-": return alt_return - raise exception(msg.format(func=func.__name__, - args=args, kwargs=kwargs)) + raise exception(msg.format(func=func.__name__, args=args, kwargs=kwargs)) + return wrapper + return func_wrap @@ -120,12 +129,15 @@ def test_2(): :param lock: Which lock to use, uses unique default """ + def func_wrapper(func): @wraps(func) def wrapper(*args, **kwargs): with lock: return func(*args, **kwargs) + return wrapper + return func_wrapper @@ -161,35 +173,33 @@ def test_time(length): :param message: string to format with total time as the only input :param append: list to append item too """ + def func_wrapper(func): @wraps(func) def wrapper(*args, **kwargs): # Can't use nonlocal in 2.x - msg = (message if message else - "Function '{func}' took a total of {seconds} seconds") + msg = message if message else "Function '{func}' took a total of {seconds} seconds" if not message: msg = _add_args(msg, *args, **kwargs) - time_func = (time.perf_counter if python_version >= (3, 3) - else time.time) + time_func = time.perf_counter if python_version >= (3, 3) else time.time start_time = time_func() try: return func(*args, **kwargs) finally: total_time = time_func() - start_time - time_string = msg.format(func=func.__name__, - seconds=total_time, - args=args, kwargs=kwargs) + time_string = msg.format(func=func.__name__, seconds=total_time, args=args, kwargs=kwargs) if log: - my_logger = logging.getLogger(log) if isinstance(log, str)\ - else logger + my_logger = logging.getLogger(log) if isinstance(log, str) else reusables_logger my_logger.info(time_string) else: print(time_string) if isinstance(append, list): append.append(total_time) + return wrapper + return func_wrapper @@ -216,16 +226,20 @@ def func(a): :param queue: Queue to add result into """ + def func_wrapper(func): @wraps(func) def wrapper(*args, **kwargs): queue.put(func(*args, **kwargs), **put_args) + return wrapper + return func_wrapper -def log_exception(log="reusables", message=None, exceptions=(Exception, ), - level=logging.ERROR, show_traceback=True): +def log_exception( + log="reusables", message=None, exceptions=(Exception,), level=logging.ERROR, show_traceback=True, suppress=False +): """ Wrapper. Log the traceback to any exceptions raised. Possible to raise custom exception. @@ -246,11 +260,20 @@ def test(): Message format options: {func} {err} {args} {kwargs} :param exceptions: types of exceptions to catch + :type exceptions: tuple :param log: log name to use + :type log: str or logging.Logger :param message: message to use in log + :type message: str :param level: logging level + :type level: str :param show_traceback: include full traceback or just error message + :type show_traceback: bool + :param suppress: Whether to raise or suppress the exception after logging it, defaults to False + :type suppress: bool """ + logger = logging.getLogger(log) if isinstance(log, str) else log + def func_wrapper(func): @wraps(func) def wrapper(*args, **kwargs): @@ -261,18 +284,20 @@ def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except exceptions as err: - my_logger = (logging.getLogger(log) if isinstance(log, str) - else log) - my_logger.log(level, msg.format(func=func.__name__, - err=str(err), - args=args, kwargs=kwargs), - exc_info=show_traceback) - raise err + logger.log( + level, + msg.format(func=func.__name__, err=str(err), args=args, kwargs=kwargs), + exc_info=show_traceback, + ) + if not suppress: + raise err + return wrapper + return func_wrapper -def catch_it(exceptions=(Exception, ), default=None, handler=None): +def catch_it(exceptions=(Exception,), default=None, handler=None): """ If the function encounters an exception, catch it, and return the specified default or sent to a handler function instead. @@ -291,6 +316,7 @@ def will_raise(message="Hello") :param default: what to return if the exception is caught :param handler: function to send exception, func, *args and **kwargs """ + def func_wrapper(func): @wraps(func) def wrapper(*args, **kwargs): @@ -300,12 +326,15 @@ def wrapper(*args, **kwargs): if handler: return handler(err, func, *args, **kwargs) return default + return wrapper + return func_wrapper -def retry_it(exceptions=(Exception, ), tries=10, wait=0, handler=None, - raised_exception=ReusablesError, raised_message=None): +def retry_it( + exceptions=(Exception,), tries=10, wait=0, handler=None, raised_exception=ReusablesError, raised_message=None +): """ Retry a function if an exception is raised, or if output_check returns False. @@ -315,15 +344,15 @@ def retry_it(exceptions=(Exception, ), tries=10, wait=0, handler=None, :param exceptions: tuple of exceptions to catch :param tries: number of tries to retry the function :param wait: time to wait between executions in seconds - :param handler: function to check if output is valid, must return bool + :param handler: function to check if output is valid, must return bool :param raised_exception: default is ReusablesError :param raised_message: message to pass to raised exception """ + def func_wrapper(func): @wraps(func) def wrapper(*args, **kwargs): - msg = (raised_message if raised_message - else "Max retries exceeded for function '{func}'") + msg = raised_message if raised_message else "Max retries exceeded for function '{func}'" if not raised_message: msg = _add_args(msg, *args, **kwargs) try: @@ -332,21 +361,46 @@ def wrapper(*args, **kwargs): if tries: if wait: time.sleep(wait) - return retry_it(exceptions=exceptions, tries=tries-1, - handler=handler, - wait=wait)(func)(*args, **kwargs) + return retry_it(exceptions=exceptions, tries=tries - 1, handler=handler, wait=wait)(func)( + *args, **kwargs + ) if raised_exception: - exc = raised_exception(msg.format(func=func.__name__, - args=args, kwargs=kwargs)) + exc = raised_exception(msg.format(func=func.__name__, args=args, kwargs=kwargs)) exc.__cause__ = None raise exc else: if handler: if not handler(result): - return retry_it(exceptions=exceptions, tries=tries - 1, - handler=handler, - wait=wait)(func)(*args, **kwargs) + return retry_it(exceptions=exceptions, tries=tries - 1, handler=handler, wait=wait)(func)( + *args, **kwargs + ) return result + return wrapper + return func_wrapper + +def log_it(logger=None): + """ + Decorator that logs function calls with their arguments to the specified logger. + :param logger: logger to log function calls to, defaults to the root logger + :type logger: logging.Logger + """ + if logger is None: + logger = logging.getLogger() + elif isinstance(logger, str): + logger = logging.getLogger(logger) + + def wrapper(func): + def decorated(*a, **kw): + nonlocal logger + args = list(zip(func.__code__.co_varnames, a)) + kwargs = list(kw.items()) + arg_list = ", ".join("{}={}".format(*i) for i in args + kwargs) + logger.info("Calling %s with arguments %s", func.__name__, arg_list) + return func(*a, **kw) + + return decorated + + return wrapper diff --git a/setup.py b/setup.py index f3fe816..a63b4d5 100644 --- a/setup.py +++ b/setup.py @@ -18,54 +18,50 @@ with open("README.rst", "r") as readme_file: long_description = readme_file.read() -packages = ['reusables', 'test'] +packages = ["reusables", "test"] setup( - name='reusables', - version=attrs['version'], - url='https://github.com/cdgriffith/Reusables', - license='MIT', - author=attrs['author'], - tests_require=["pytest", "coverage >= 3.6", "argparse", "rarfile", - "tox", "scandir", "pytest-cov"], + name="reusables", + version=attrs["version"], + url="https://github.com/cdgriffith/Reusables", + license="MIT", + author=attrs["author"], + tests_require=["pytest", "coverage >= 3.6", "argparse", "rarfile", "tox", "scandir", "pytest-cov"], install_requires=[], - author_email='chris@cdgriffith.com', - description='Commonly Consumed Code Commodities', + author_email="chris@cdgriffith.com", + description="Commonly Consumed Code Commodities", long_description=long_description, packages=packages, include_package_data=True, - platforms='any', - setup_requires=['pytest-runner'], + platforms="any", + setup_requires=["pytest-runner"], classifiers=[ - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Development Status :: 4 - Beta', - 'Natural Language :: English', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Topic :: Utilities', - 'Topic :: Software Development', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Documentation :: Sphinx', - 'Topic :: System :: Archiving', - 'Topic :: System :: Archiving :: Compression', - 'Topic :: System :: Filesystems', - 'Topic :: System :: Logging' - ], - extras_require={ - 'testing': ["pytest", "coverage >= 3.6", "argparse", "rarfile", - "tox", "scandir", "pytest-cov"], - }, + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Development Status :: 4 - Beta", + "Natural Language :: English", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Utilities", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Documentation :: Sphinx", + "Topic :: System :: Archiving", + "Topic :: System :: Archiving :: Compression", + "Topic :: System :: Filesystems", + "Topic :: System :: Logging", + ], + extras_require={"testing": ["pytest", "coverage >= 3.6", "argparse", "rarfile", "tox", "scandir", "pytest-cov"],}, ) diff --git a/test/__init__.py b/test/__init__.py index 8cb7f45..e69de29 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,2 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- diff --git a/test/common_test_data.py b/test/common_test_data.py index 91ddbce..a61d86a 100644 --- a/test/common_test_data.py +++ b/test/common_test_data.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- import os import unittest import shutil @@ -14,7 +14,6 @@ class BaseTestClass(unittest.TestCase): - @classmethod def tearDownClass(cls): try: diff --git a/test/test_cli.py b/test/test_cli.py index 047f959..6501c92 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- from reusables.cli import * from .common_test_data import * @@ -22,6 +22,7 @@ def setUpClass(cls): def test_cmd(self): import sys + save_file = os.path.join(data_dr, "stdout") saved = sys.stdout sys.stdout = open(save_file, "w") @@ -55,13 +56,13 @@ def test_ls(self): def test_head(self): lines = head(self.ex, printed=False) assert len(lines) == 5, len(lines) - assert 'file!' in lines[-1], lines + assert "file!" in lines[-1], lines head(self.ex, printed=True) def test_tail(self): lines = tail(self.ex, lines=2, printed=False) assert len(lines) == 2, len(lines) - assert 'file!' in lines[-1], lines + assert "file!" in lines[-1], lines tail(self.ex, printed=True) def test_cat(self): @@ -86,4 +87,3 @@ def test_cp(self): os.unlink("test_file2") except OSError: pass - diff --git a/test/test_numbers.py b/test/test_numbers.py index af5a93d..a6217bd 100644 --- a/test/test_numbers.py +++ b/test/test_numbers.py @@ -1,55 +1,142 @@ +# -*- coding: utf-8 -*- import reusables from .common_test_data import * -roman_list = [(1, 'I'), (2, 'II'), (3, 'III'), (4, 'IV'), (5, 'V'), (6, 'VI'), - (7, 'VII'), (8, 'VIII'), (9, 'IX'), (10, 'X'), (11, 'XI'), - (12, 'XII'), (13, 'XIII'), (14, 'XIV'), (15, 'XV'), (16, 'XVI'), - (17, 'XVII'), (18, 'XVIII'), (19, 'XIX'), (20, 'XX'), (21, 'XXI'), - (22, 'XXII'), (23, 'XXIII'), (24, 'XXIV'), (25, 'XXV'), - (26, 'XXVI'), (27, 'XXVII'), (28, 'XXVIII'), (29, 'XXIX'), - (30, 'XXX'), (31, 'XXXI'), (32, 'XXXII'), (33, 'XXXIII'), - (34, 'XXXIV'), (35, 'XXXV'), (36, 'XXXVI'), (37, 'XXXVII'), - (38, 'XXXVIII'), (39, 'XXXIX'), (40, 'XL'), (41, 'XLI'), - (42, 'XLII'), (43, 'XLIII'), (44, 'XLIV'), (45, 'XLV'), - (46, 'XLVI'), (47, 'XLVII'), (48, 'XLVIII'), (49, 'XLIX'), - (50, 'L'), (51, 'LI'), (52, 'LII'), (53, 'LIII'), (54, 'LIV'), - (55, 'LV'), (56, 'LVI'), (57, 'LVII'), (58, 'LVIII'), - (59, 'LIX'), (60, 'LX'), (61, 'LXI'), (62, 'LXII'), (63, 'LXIII'), - (64, 'LXIV'), (65, 'LXV'), (66, 'LXVI'), (67, 'LXVII'), - (68, 'LXVIII'), (69, 'LXIX'), (70, 'LXX'), (71, 'LXXI'), - (72, 'LXXII'), (73, 'LXXIII'), (74, 'LXXIV'), (75, 'LXXV'), - (76, 'LXXVI'), (77, 'LXXVII'), (78, 'LXXVIII'), (79, 'LXXIX'), - (80, 'LXXX'), (81, 'LXXXI'), (82, 'LXXXII'), (83, 'LXXXIII'), - (84, 'LXXXIV'), (85, 'LXXXV'), (86, 'LXXXVI'), (87, 'LXXXVII'), - (88, 'LXXXVIII'), (89, 'LXXXIX'), (90, 'XC'), (91, 'XCI'), - (92, 'XCII'), (93, 'XCIII'), (94, 'XCIV'), (95, 'XCV'), - (96, 'XCVI'), (97, 'XCVII'), (98, 'XCVIII'), (99, 'XCIX'), - (100, 'C'), (501, 'DI'), (530, 'DXXX'), (550, 'DL'), - (707, 'DCCVII'), (890, 'DCCCXC'), (900, 'CM'), (1500, 'MD'), - (1800, 'MDCCC')] - -numbers_list = [(0, 'zero'), ('1,000.00', 'one thousand'), - (1000.00, 'one thousand'), - (18005607, 'eighteen million, five thousand, six hundred seven'), - (13.13, 'thirteen and thirteen hundredths'), - ('1', 'one'), ('1.0', 'one'), - (89.1, 'eighty-nine and one tenths'), - ('89.1', 'eighty-nine and one tenths'), - ('1.00012', 'one and twelve hundred-thousandths'), - (10.10, 'ten and one tenths'), - (0.11111, 'zero and eleven thousand one hundred eleven ' - 'hundred-thousandths')] - -european_numbers = [('123.456.789', - 'one hundred twenty-three million, four hundred ' - 'fifty-six thousand, seven hundred eighty-nine'), - ('1,345', 'one and three hundred forty-five thousandths'), - ('10.000,5', 'ten thousand and five tenths')] +roman_list = [ + (1, "I"), + (2, "II"), + (3, "III"), + (4, "IV"), + (5, "V"), + (6, "VI"), + (7, "VII"), + (8, "VIII"), + (9, "IX"), + (10, "X"), + (11, "XI"), + (12, "XII"), + (13, "XIII"), + (14, "XIV"), + (15, "XV"), + (16, "XVI"), + (17, "XVII"), + (18, "XVIII"), + (19, "XIX"), + (20, "XX"), + (21, "XXI"), + (22, "XXII"), + (23, "XXIII"), + (24, "XXIV"), + (25, "XXV"), + (26, "XXVI"), + (27, "XXVII"), + (28, "XXVIII"), + (29, "XXIX"), + (30, "XXX"), + (31, "XXXI"), + (32, "XXXII"), + (33, "XXXIII"), + (34, "XXXIV"), + (35, "XXXV"), + (36, "XXXVI"), + (37, "XXXVII"), + (38, "XXXVIII"), + (39, "XXXIX"), + (40, "XL"), + (41, "XLI"), + (42, "XLII"), + (43, "XLIII"), + (44, "XLIV"), + (45, "XLV"), + (46, "XLVI"), + (47, "XLVII"), + (48, "XLVIII"), + (49, "XLIX"), + (50, "L"), + (51, "LI"), + (52, "LII"), + (53, "LIII"), + (54, "LIV"), + (55, "LV"), + (56, "LVI"), + (57, "LVII"), + (58, "LVIII"), + (59, "LIX"), + (60, "LX"), + (61, "LXI"), + (62, "LXII"), + (63, "LXIII"), + (64, "LXIV"), + (65, "LXV"), + (66, "LXVI"), + (67, "LXVII"), + (68, "LXVIII"), + (69, "LXIX"), + (70, "LXX"), + (71, "LXXI"), + (72, "LXXII"), + (73, "LXXIII"), + (74, "LXXIV"), + (75, "LXXV"), + (76, "LXXVI"), + (77, "LXXVII"), + (78, "LXXVIII"), + (79, "LXXIX"), + (80, "LXXX"), + (81, "LXXXI"), + (82, "LXXXII"), + (83, "LXXXIII"), + (84, "LXXXIV"), + (85, "LXXXV"), + (86, "LXXXVI"), + (87, "LXXXVII"), + (88, "LXXXVIII"), + (89, "LXXXIX"), + (90, "XC"), + (91, "XCI"), + (92, "XCII"), + (93, "XCIII"), + (94, "XCIV"), + (95, "XCV"), + (96, "XCVI"), + (97, "XCVII"), + (98, "XCVIII"), + (99, "XCIX"), + (100, "C"), + (501, "DI"), + (530, "DXXX"), + (550, "DL"), + (707, "DCCVII"), + (890, "DCCCXC"), + (900, "CM"), + (1500, "MD"), + (1800, "MDCCC"), +] + +numbers_list = [ + (0, "zero"), + ("1,000.00", "one thousand"), + (1000.00, "one thousand"), + (18005607, "eighteen million, five thousand, six hundred seven"), + (13.13, "thirteen and thirteen hundredths"), + ("1", "one"), + ("1.0", "one"), + (89.1, "eighty-nine and one tenths"), + ("89.1", "eighty-nine and one tenths"), + ("1.00012", "one and twelve hundred-thousandths"), + (10.10, "ten and one tenths"), + (0.11111, "zero and eleven thousand one hundred eleven " "hundred-thousandths"), +] + +european_numbers = [ + ("123.456.789", "one hundred twenty-three million, four hundred " "fifty-six thousand, seven hundred eighty-nine"), + ("1,345", "one and three hundred forty-five thousandths"), + ("10.000,5", "ten thousand and five tenths"), +] class TestNumbers(BaseTestClass): - def test_roman_from_int(self): for line in roman_list: value = reusables.int_to_roman(int(line[0])) @@ -98,8 +185,7 @@ def test_roman_to_int(self): def test_int_to_words(self): for pair in numbers_list: - assert reusables.int_to_words(pair[0]) == pair[1], \ - "Couldn't translate {0}".format(pair[0]) + assert reusables.int_to_words(pair[0]) == pair[1], "Couldn't translate {0}".format(pair[0]) def test_bad_ints_to_words(self): try: @@ -118,5 +204,4 @@ def test_bad_ints_to_words(self): def test_european_ints(self): for pair in european_numbers: - assert reusables.int_to_words(pair[0], european=True) == pair[1], \ - "Couldn't translate {0}".format(pair[0]) + assert reusables.int_to_words(pair[0], european=True) == pair[1], "Couldn't translate {0}".format(pair[0]) diff --git a/test/test_other.py b/test/test_other.py index 2f8db22..aec3b33 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- import reusables from .common_test_data import * @@ -9,8 +9,38 @@ class TestException(Exception): pass -class TestOther(BaseTestClass): +class Foo(metaclass=reusables.Singleton): + def __init__(self, thing): + self.thing = thing + + def __call__(self): + return self.thing + +class TestOther(BaseTestClass): def test_exception_ignored(self): with reusables.ignored(TestException): raise TestException() + + def test_defaultlist_init(self): + test = reusables.defaultlist(factory=int) + + def test_defaultlist_length(self): + test = reusables.defaultlist(factory=int) + self.assertEqual(len(test), 0) + x = test[5] + self.assertEqual(x, 0) + self.assertIsInstance(x, int) + self.assertEqual(len(test), 6) + + def test_defaultlist_list_factory(self): + test = reusables.defaultlist(factory=list) + test[2].append(10) + self.assertEqual(test, [[], [], [10]]) + + def test_singleton(self): + """Singleton design pattern test class.""" + foo = Foo("BAR") + self.assertIs(foo, Foo("BAZ")) + self.assertEqual(foo.thing, "BAR") + self.assertEqual(Foo("BAZ"), "BAR") diff --git a/test/test_reuse.py b/test/test_reuse.py index 9a3aee1..d11d545 100644 --- a/test/test_reuse.py +++ b/test/test_reuse.py @@ -11,7 +11,6 @@ class TestReuse(BaseTestClass): - @classmethod def setUpClass(cls): config_file = """[Section1] @@ -25,28 +24,26 @@ def setUpClass(cls): if os.path.exists(test_structure): shutil.rmtree(test_structure) - def test_get_config_dict(self): - resp = reusables.config_dict(os.path.join(test_root, 'test_config.cfg')) - assert resp['Section1']['key 1'] == 'value 1' - assert resp['Section 2'] == {} + resp = reusables.config_dict(os.path.join(test_root, "test_config.cfg")) + assert resp["Section1"]["key 1"] == "value 1" + assert resp["Section 2"] == {} def test_get_config_dict_auto(self): resp = reusables.config_dict(auto_find=test_root) - assert resp.get('Section1') == {'key 1': 'value 1', 'key2': 'Value2'}, resp.get('Section1') + assert resp.get("Section1") == {"key 1": "value 1", "key2": "Value2"}, resp.get("Section1") def test_get_config_dict_no_verify(self): - resp = reusables.config_dict('bad_loc.cfg', verify=False) + resp = reusables.config_dict("bad_loc.cfg", verify=False) assert resp == {}, resp def test_get_config_dict_multiple(self): - resp = reusables.config_dict([os.path.join(test_root, 'test_config.cfg')]) - assert resp == {'Section1': {'key 1': 'value 1', 'key2': 'Value2'}, 'Section 2': {}}, resp + resp = reusables.config_dict([os.path.join(test_root, "test_config.cfg")]) + assert resp == {"Section1": {"key 1": "value 1", "key2": "Value2"}, "Section 2": {}}, resp def test_get_config_namespace(self): - resp = reusables.config_namespace(os.path.join(test_root, - 'test_config.cfg')) - assert resp.Section1.key2 == 'Value2', str(resp.Section1) + resp = reusables.config_namespace(os.path.join(test_root, "test_config.cfg")) + assert resp.Section1.key2 == "Value2", str(resp.Section1) def test_check_bad_filename(self): resp = reusables.check_filename("safeFile?.text") @@ -57,8 +54,8 @@ def test_check_good_filename(self): assert resp def test_safe_bad_filename(self): - resp = reusables.safe_filename("<\">ThatsNotaFileName.\0ThatsASpaceShip^&*") - assert not [x for x in ("\"", "?", "\0", "<", ">", "*") if x in resp], resp + resp = reusables.safe_filename('<">ThatsNotaFileName.\0ThatsASpaceShip^&*') + assert not [x for x in ('"', "?", "\0", "<", ">", "*") if x in resp], resp assert reusables.check_filename(resp) def test_safe_good_filename(self): @@ -67,7 +64,7 @@ def test_safe_good_filename(self): assert resp == infilename, resp def test_type_errors(self): - self.assertRaises(TypeError, reusables.config_dict, dict(config='1')) + self.assertRaises(TypeError, reusables.config_dict, dict(config="1")) self.assertRaises(TypeError, reusables.check_filename, tuple()) self.assertRaises(TypeError, reusables.safe_filename, set()) self.assertRaises(TypeError, reusables.safe_path, dict()) @@ -75,7 +72,7 @@ def test_type_errors(self): def test_hash_file(self): valid = "81dc9bdb52d04dc20036dbd8313ed055" hash_file = "1234" - with open(os.path.join(test_root, "test_hash"), 'w') as out_hash: + with open(os.path.join(test_root, "test_hash"), "w") as out_hash: out_hash.write(hash_file) resp = reusables.file_hash(os.path.join(test_root, "test_hash")) os.unlink(os.path.join(test_root, "test_hash")) @@ -92,8 +89,7 @@ def test_count_files(self): def test_count_name(self): self._extract_structure() resp = reusables.count_files(test_root, name="file_") - assert resp == 4, reusables.find_files_list(test_root, name="file_", - abspath=True) + assert resp == 4, reusables.find_files_list(test_root, name="file_", abspath=True) def test_fail_count_files(self): self._extract_structure() @@ -117,7 +113,7 @@ def test_find_files_name(self): assert [x for x in resp if x.endswith(os.path.join(test_root, "test_config.cfg"))] def test_find_files_bad_ext(self): - resp = iter(reusables.find_files(test_root, ext={'test': '.txt'}, disable_pathlib=True)) + resp = iter(reusables.find_files(test_root, ext={"test": ".txt"}, disable_pathlib=True)) self.assertRaises(TypeError, next, resp) def test_find_file_sad_bad(self): @@ -142,8 +138,8 @@ def test_find_files_iterator(self): assert [x for x in resp if x.endswith(os.path.join(test_root, "test_config.cfg"))] def test_path_single(self): - resp = reusables.safe_path('path') - assert resp == 'path', resp + resp = reusables.safe_path("path") + assert resp == "path", resp def _extract_structure(self): tar = tarfile.open(test_structure_tar) @@ -187,6 +183,7 @@ def test_extract_zip(self): def test_extract_rar(self): if reusables.win_based: import rarfile + rarfile.UNRAR_TOOL = os.path.abspath(os.path.join(test_root, "UnRAR.exe")) assert os.path.exists(test_structure_rar) reusables.extract(test_structure_rar, path=test_root, delete_on_success=False, enable_rar=True) @@ -259,9 +256,11 @@ def test_bad_file_hash_type(self): assert False def test_csv(self): - matrix = [["Date", "System", "Size", "INFO"], - ["2016-05-10", "MAIN", 456, [1, 2]], - ["2016-06-11", "SECONDARY", 4556, 66]] + matrix = [ + ["Date", "System", "Size", "INFO"], + ["2016-05-10", "MAIN", 456, [1, 2]], + ["2016-06-11", "SECONDARY", 4556, 66], + ] afile = reusables.join_paths(test_root, "test.csv") try: reusables.list_to_csv(matrix, afile) @@ -274,8 +273,8 @@ def test_csv(self): assert len(from_save) == 3 assert from_save[0] == ["Date", "System", "Size", "INFO"], from_save[0] - assert from_save[1] == ["2016-05-10", "MAIN", '456', '[1, 2]'], from_save[1] - assert from_save[2] == ["2016-06-11", "SECONDARY", '4556', '66'], from_save[2] + assert from_save[1] == ["2016-05-10", "MAIN", "456", "[1, 2]"], from_save[1] + assert from_save[2] == ["2016-06-11", "SECONDARY", "4556", "66"], from_save[2] def test_json_save(self): test_data = {"Hello": ["how", "are"], "You": "?", "I'm": True, "fine": 5} @@ -299,40 +298,40 @@ def test_dup_empty(self): print(b) def test_config_reader(self): - cfg = reusables.config_namespace( - reusables.join_paths(test_root, "data", "test_config.ini")) + cfg = reusables.config_namespace(reusables.join_paths(test_root, "data", "test_config.ini")) assert isinstance(cfg, reusables.ConfigNamespace) assert cfg.General.example == "A regular string" - assert cfg["Section 2"].list( - "exampleList", mod=lambda x: int(x)) == [234, 123, 234, 543] + assert cfg["Section 2"].list("exampleList", mod=lambda x: int(x)) == [234, 123, 234, 543] def test_config_reader_bad(self): try: - cfg = reusables.config_namespace( - reusables.join_paths(test_root, "data", "test_bad_config.ini")) + cfg = reusables.config_namespace(reusables.join_paths(test_root, "data", "test_bad_config.ini")) except AttributeError: pass else: assert False def test_run(self): - cl = reusables.run('echo test', shell=True, stderr=None, copy_local_env=True) + cl = reusables.run("echo test", shell=True, stderr=None, copy_local_env=True) try: cl.check_returncode() except subprocess.CalledProcessError: pass - assert cl.stdout == (b'test\n' if reusables.nix_based else b'test\r\n'), cl + assert cl.stdout == (b"test\n" if reusables.nix_based else b"test\r\n"), cl import platform - outstr = "CompletedProcess(args='echo test', returncode=0{2}, stdout={0}'test{1}\\n')".format('b' if reusables.PY3 else '', - '\\r' if reusables.win_based else '', - 'L' if reusables.win_based and platform.python_implementation() == 'PyPy' else '') + + outstr = "CompletedProcess(args='echo test', returncode=0{2}, stdout={0}'test{1}\\n')".format( + "b" if reusables.PY3 else "", + "\\r" if reusables.win_based else "", + "L" if reusables.win_based and platform.python_implementation() == "PyPy" else "", + ) assert str(cl) == outstr, "{0} != {1}".format(str(cl), outstr) try: - cl2 = reusables.run('echo test', shell=True, timeout=5) + cl2 = reusables.run("echo test", shell=True, timeout=5) except NotImplementedError: if reusables.PY3: raise AssertionError("Should only happen on PY2") @@ -341,7 +340,7 @@ def test_run(self): if reusables.PY2: raise AssertionError("Timeout should not have worked for PY2") - cl3 = reusables.run('exit 1', shell=True) + cl3 = reusables.run("exit 1", shell=True) try: cl3.check_returncode() except subprocess.CalledProcessError: @@ -368,7 +367,7 @@ def test_dups(self): def test_cut(self): a = reusables.cut("abcdefghi") - assert a == ['ab', 'cd', 'ef', 'gh', 'i'] + assert a == ["ab", "cd", "ef", "gh", "i"] try: reusables.cut("abcdefghi", 2, "error") @@ -378,10 +377,10 @@ def test_cut(self): raise AssertionError("cut failed") b = reusables.cut("abcdefghi", 2, "remove") - assert b == ['ab', 'cd', 'ef', 'gh'] + assert b == ["ab", "cd", "ef", "gh"] c = reusables.cut("abcdefghi", 2, "combine") - assert c == ['ab', 'cd', 'ef', 'ghi'] + assert c == ["ab", "cd", "ef", "ghi"] def test_find_glob(self): resp = reusables.find_files_list(test_root, name="*config*") @@ -446,11 +445,10 @@ def test_remove_with_scandir(self): def test_find_file_pathlib(self): if reusables.python_version >= (3, 4): import pathlib - files = reusables.find_files_list(test_root, ext=".cfg", - abspath=True) + + files = reusables.find_files_list(test_root, ext=".cfg", abspath=True) assert isinstance(files[0], pathlib.Path) - files2 = reusables.find_files_list(test_root, ext=".cfg", - abspath=True, disable_pathlib=True) + files2 = reusables.find_files_list(test_root, ext=".cfg", abspath=True, disable_pathlib=True) assert not isinstance(files2[0], pathlib.Path) def test_sync_dirs(self): @@ -459,15 +457,14 @@ def test_sync_dirs(self): files = reusables.find_files_list(test_root, ext=".cfg", abspath=True) - if reusables.nix_based: + class TestReuseLinux(unittest.TestCase): def test_safe_bad_path(self): path = "/var/lib\\/test/p?!ath/fi\0lename.txt" expected = "/var/lib_/test/p__ath/fi_lename.txt" resp = reusables.safe_path(path) - assert not [x for x in ("!", "?", "\0", "^", "&", "*") if - x in resp], resp + assert not [x for x in ("!", "?", "\0", "^", "&", "*") if x in resp], resp assert resp == expected, resp def test_safe_good_path(self): @@ -476,38 +473,39 @@ def test_safe_good_path(self): assert resp == path, resp def test_join_path_clean(self): - resp = reusables.join_paths('/test/', 'clean/', 'path') - assert resp == '/test/clean/path', resp + resp = reusables.join_paths("/test/", "clean/", "path") + assert resp == "/test/clean/path", resp def test_join_path_dirty(self): - resp = reusables.join_paths('/test/', '/dirty/', ' path.file ') - assert resp == '/test/dirty/path.file', resp + resp = reusables.join_paths("/test/", "/dirty/", " path.file ") + assert resp == "/test/dirty/path.file", resp def test_join_path_clean_strict(self): - resp = reusables.join_paths('/test/', 'clean/', 'path/') - assert resp == '/test/clean/path/', resp + resp = reusables.join_paths("/test/", "clean/", "path/") + assert resp == "/test/clean/path/", resp def test_join_here(self): - resp = reusables.join_here('clean/') - path = os.path.abspath(os.path.join(".", 'clean/')) + resp = reusables.join_here("clean/") + path = os.path.abspath(os.path.join(".", "clean/")) assert resp == path, (resp, path) if reusables.win_based: + class TestReuseWindows(unittest.TestCase): - # Windows based path tests + # Windows based path tests def test_win_join_path_clean(self): - resp = reusables.join_paths('C:\\test', 'clean\\', 'path').rstrip("\\") - assert resp == 'C:\\test\\clean\\path', resp + resp = reusables.join_paths("C:\\test", "clean\\", "path").rstrip("\\") + assert resp == "C:\\test\\clean\\path", resp def test_win_join_path_dirty(self): - resp = reusables.join_paths('C:\\test\\', 'D:\\dirty', ' path.file ') - assert resp == 'D:\\dirty\\path.file', resp + resp = reusables.join_paths("C:\\test\\", "D:\\dirty", " path.file ") + assert resp == "D:\\dirty\\path.file", resp def test_win_join_here(self): - resp = reusables.join_here('clean\\') - path = os.path.abspath(os.path.join(".", 'clean\\')) + resp = reusables.join_here("clean\\") + path = os.path.abspath(os.path.join(".", "clean\\")) assert resp == path, (resp, path) def test_cant_delete_empty_file(self): diff --git a/test/test_reuse_datetime.py b/test/test_reuse_datetime.py index 41daae3..c7ba19d 100644 --- a/test/test_reuse_datetime.py +++ b/test/test_reuse_datetime.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- import datetime import reusables @@ -8,7 +8,6 @@ class TestDateTime(BaseTestClass): - def test_datetime_from_iso(self): test = datetime.datetime.now() testiso = test.isoformat() @@ -41,11 +40,10 @@ def test_datetime_new(self): assert now.hour == today.hour def test_datetime_format(self): - assert reusables.dtf("{hour}:{minute}:{second}" - ) == datetime.datetime.now().strftime("%I:%M:%S") - assert reusables.dtf("{hour}:{minute}:{hour}:{24hour}:{24-hour}" - ) == datetime.datetime.now().strftime( - "%I:%M:%I:%H:%H") + assert reusables.dtf("{hour}:{minute}:{second}") == datetime.datetime.now().strftime("%I:%M:%S") + assert reusables.dtf("{hour}:{minute}:{hour}:{24hour}:{24-hour}") == datetime.datetime.now().strftime( + "%I:%M:%I:%H:%H" + ) def test_now(self): now = reusables.now() diff --git a/test/test_reuse_logging.py b/test/test_reuse_logging.py index 7d8ca97..365356d 100644 --- a/test/test_reuse_logging.py +++ b/test/test_reuse_logging.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- import logging @@ -12,6 +12,7 @@ my_fiie_path = os.path.join(test_root, "my_file.log") if sys.version_info < (2, 7): + class NullHandler(logging.Handler): def emit(self, record): pass @@ -20,7 +21,6 @@ def emit(self, record): class TestReuseLogging(BaseTestClass): - def setUp(self): logging.getLogger(__name__).handlers = [] if os.path.exists(my_stream_path): @@ -66,10 +66,7 @@ def test_add_file_logger(self): assert "Example error log" in lines[1] def test_change_log_level(self): - logger = reusables.setup_logger(__name__, - level=logging.WARNING, - stream=None, - file_path=my_stream_path) + logger = reusables.setup_logger(__name__, level=logging.WARNING, stream=None, file_path=my_stream_path) logger.debug("Hello There, sexy") reusables.change_logger_levels(__name__, 10) logger.debug("This isn't a good idea") @@ -79,8 +76,7 @@ def test_change_log_level(self): assert "good idea" in line, line def test_get_file_logger(self): - logger = reusables.setup_logger(__name__, stream=None, - file_path=my_stream_path) + logger = reusables.setup_logger(__name__, stream=None, file_path=my_stream_path) logger.info("Test log") logger.error("Example 2nd error log") reusables.remove_file_handlers(logger) @@ -118,8 +114,8 @@ def test_remove_file_handlers(self): reusables.remove_all_handlers(logger) def test_add_rotate_file_handlers(self): - from logging.handlers import RotatingFileHandler,\ - TimedRotatingFileHandler + from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler + logger = reusables.setup_logger("add_file") reusables.remove_all_handlers(logger) reusables.add_rotating_file_handler("add_file") @@ -135,5 +131,3 @@ def test_add_simple_handlers(self): reusables.add_stream_handler("test1") assert isinstance(logger.handlers[0], logging.StreamHandler) reusables.remove_all_handlers("test1") - - diff --git a/test/test_reuse_namespace.py b/test/test_reuse_namespace.py index 22794e1..c8585c7 100644 --- a/test/test_reuse_namespace.py +++ b/test/test_reuse_namespace.py @@ -1,63 +1,54 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- import reusables from .common_test_data import * class TestReuseNamespace(BaseTestClass): - def test_namespace(self): - test_dict = {'key1': 'value1', - "Key 2": {"Key 3": "Value 3", - "Key4": {"Key5": "Value5"}}} + test_dict = {"key1": "value1", "Key 2": {"Key 3": "Value 3", "Key4": {"Key5": "Value5"}}} namespace = reusables.Namespace(**test_dict) - assert namespace.key1 == test_dict['key1'] - assert dict(getattr(namespace, 'Key 2')) == test_dict['Key 2'] - setattr(namespace, 'TEST_KEY', 'VALUE') - assert namespace.TEST_KEY == 'VALUE' - delattr(namespace, 'TEST_KEY') - assert 'TEST_KEY' not in namespace.to_dict(), namespace.to_dict() - assert isinstance(namespace['Key 2'].Key4, reusables.Namespace) + assert namespace.key1 == test_dict["key1"] + assert dict(getattr(namespace, "Key 2")) == test_dict["Key 2"] + setattr(namespace, "TEST_KEY", "VALUE") + assert namespace.TEST_KEY == "VALUE" + delattr(namespace, "TEST_KEY") + assert "TEST_KEY" not in namespace.to_dict(), namespace.to_dict() + assert isinstance(namespace["Key 2"].Key4, reusables.Namespace) assert "'key1': 'value1'" in str(namespace) assert repr(namespace).startswith("