From 764eefcf041d6749166fac56c310f681a742857e Mon Sep 17 00:00:00 2001 From: egberts Date: Sun, 14 Jul 2024 15:04:34 -0500 Subject: [PATCH] Many new unit tests for configuration settings file (pelicanconf.py) - Now reports line number/offset of pelicanconf.py syntax errors - Better error messages with full absolute resolved filespec due to: - Read-only attribute accidentially used - Non-existant file report - Prevent clobbering of Python built-in system modules by user's custom filename - Breakout test_settings.py into smaller and manageable UT files - test_settings.py - test_settings_deprecated.py - test_settings_module.py - test_settings_path.py - Start using pytest (test_settings_path.py) instead of unittest - - Set minimum Python to 3.8 (pytest 4.0) --- pelican/settings.py | 310 ++++- .../settings/pelicanconf-missing-path-var.py | 15 + .../pelicanconf-path-no-such-directory.py | 15 + .../pelicanconf-syntax-error.py.disabled | 12 + .../pelicanconf-syntax-error2.py.disabled | 18 + pelican/tests/settings/pelicanconf-valid.py | 15 + pelican/tests/test_settings.py | 186 +-- pelican/tests/test_settings_deprecated.py | 347 ++++++ pelican/tests/test_settings_module.py | 1074 +++++++++++++++++ pelican/tests/test_settings_path.py | 307 +++++ 10 files changed, 2125 insertions(+), 174 deletions(-) create mode 100644 pelican/tests/settings/pelicanconf-missing-path-var.py create mode 100644 pelican/tests/settings/pelicanconf-path-no-such-directory.py create mode 100644 pelican/tests/settings/pelicanconf-syntax-error.py.disabled create mode 100644 pelican/tests/settings/pelicanconf-syntax-error2.py.disabled create mode 100644 pelican/tests/settings/pelicanconf-valid.py create mode 100644 pelican/tests/test_settings_deprecated.py create mode 100644 pelican/tests/test_settings_module.py create mode 100644 pelican/tests/test_settings_path.py diff --git a/pelican/settings.py b/pelican/settings.py index 66d6beeb2..a949bf920 100644 --- a/pelican/settings.py +++ b/pelican/settings.py @@ -1,9 +1,11 @@ import copy +import errno import importlib.util import inspect import locale import logging import os +import pathlib import re import sys from os.path import isabs @@ -13,14 +15,7 @@ from pelican.log import LimitFilter - -def load_source(name: str, path: str) -> ModuleType: - spec = importlib.util.spec_from_file_location(name, path) - mod = importlib.util.module_from_spec(spec) - sys.modules[name] = mod - spec.loader.exec_module(mod) - return mod - +DEFAULT_MODULE_NAME: str = "pelicanconf" logger = logging.getLogger(__name__) @@ -182,13 +177,259 @@ def load_source(name: str, path: str) -> ModuleType: PYGMENTS_RST_OPTIONS = None +def load_source(name: str, path: str | pathlib.Path) -> ModuleType | None: + """ + Loads the Python-syntax file as a module for application access + + Only `path` shall be consulted for its actual file location. + + If module_name is not supplied as an argument, then its module name + shall be extracted from given `path` argument but without any directory + nor its file extension (just the basic pathLib.Path(path).stem part). + + This function substitutes Python built-in `importlib` but with one distinctive + but different feature: No attempts are made to leverage the `PYTHONPATH` as an + alternative multi-directory search of a specified module name; + only singular-directory lookup/search is supported here. + + WARNING to DEVELOPER: If you programmatically used the "from" reserved + Python keyword as in this "from pelicanconf import ..." statement, then you + will not be able to free up the pelicanconf module, much + less use 'reload module' features. (Not a likely scenario, but) + + :param path: filespec of the Python script file to load as Python module; + `path` parameter may be a filename which is looked for in the + current working directory, or a relative filename that looks + only in that particular directory, or an absolute filename + that also looks only in that absolute directory. + :type path: str | pathlib.Path + :param name: Optional argument to the Python module name to be loaded. + `module_name` shall never use dotted notation nor any + directory separator, just the plain filename (without + any extension/suffix part). + :type name: str + :return: the ModuleType of the loaded Python module file. Will be + accessible in a form of "pelican.". + :rtype: ModuleType | None + """ + if isinstance(path, str): + conf_filespec = pathlib.Path(path) + elif isinstance(path, pathlib.Path): + conf_filespec = path + else: + logger.fatal( + f"argument {path.__str__()} is not a pathLib.Path type nor a str type." + ) + raise TypeError + + absolute_filespec = conf_filespec.absolute() + filename_ext = conf_filespec.name + if not absolute_filespec.exists(): + logger.error(f"File '{filename_ext!s}' not found.") + return None + if not absolute_filespec.is_file(): + logger.error(f"Absolute '{conf_filespec!s}' path is not a file.") + return None + if not os.access(str(absolute_filespec), os.R_OK): + logger.error(f"'{absolute_filespec}' file is not readable.") + return None + resolved_absolute_filespec = absolute_filespec.resolve(strict=True) + + # We used to strip '.py' extension to make a module name out of it + # But we cannot take in anymore the user-supplied path for anything + # (such as Pelican configuration settings file) as THE Python module + # name for Pelican due to potential conflict with 300+ names of Python + # built-in system module, such as `site.conf`, `calendar.conf`. + # A complete list is in https://docs.python.org/3/py-modindex.html. + # Instead, force the caller of load_source to supply a Pelican-specific but + # statically fixed module_name to be associated with the end-user's choice + # of configuration filename. + + module_name = "" + # if load_source(path=...) is used + if "name" not in locals(): + # old load_source(path) prototype + module_name = pathlib.Path(path).stem + + # if load_source(None, =...) is used + if name is None: + logger.warning( + f"Module name is missing; using Python built-in to check " + f"PYTHONPATH for {absolute_filespec}" + ) + # if load_source(value: not str, =...) is used + elif not isinstance(name, str): + raise TypeError + # if load_source(name="", =...) is used + elif name == "": + module_name = pathlib.Path(path).stem + # if load_source(value: not str, =...) is used + elif isinstance(name, str): + module_name = name + else: + raise TypeError("load_source(name=...) argument is not a str type") + + # One last thing to do before sys.modules check, is to deny any dotted module name + # This is a Pelican design issue (to support pelicanconf reloadability) + # + # Alternatively, do we want to support the approach of Python 'pelican.conf' + # module? Yes, but we couldn't, while it is the correct design to nest + # the configuration settings as a submodule behind Pelican (we should be + # able to), but Pelican design is restricted by Python sys.modules design. + # + # Also, we lose reload() capability once the alternative desired design of + # 'conf' submodule gets loaded by Python 'from' keyword. + # + # Hence, we do not support 'pelican.conf' due to Pelican reloadability requirement + # + # Judge have ruled the 'period' symbol in module name as disallowable. + if "." in module_name: + # load_connect() should return sys.exit by design, but no. + # Below fatal log is used AS-IS by test_settings_module.py unit test + logger.fatal(f"Cannot use dotted module name such as `{module_name}`.") + # Nothing fancy, return None + return None + + # Nonetheless, we check that this module_name is not taken as well. + # Check that the module name is not in sys.module (like pathlib!!!) + if module_name in sys.modules: + # following logger.fatal is used as-is by test_settings_module.py unit test + logger.fatal( + f"Cannot reserved the module name already used" + f" by Python system module `{module_name}`." + ) + sys.exit(errno.EPERM) + + # if module_name == "": + # module_name = "pelicanconf" + + try: + # Using Python importlib, find the module using a full file path + # specification and its filename and return this Module instance + module_spec = importlib.util.spec_from_file_location( + module_name, resolved_absolute_filespec + ) + logger.debug(f"ModuleSpec '{module_name}' obtained from {absolute_filespec}.") + except ImportError: + logger.fatal( + f"Location {resolved_absolute_filespec} may be missing a " + f"module `get_filename` attribute value." + ) + raise ModuleNotFoundError from ImportError + except OSError: + logger.error( + f"Python module loader for configuration settings file " + f"cannot determine absolute directory path from {absolute_filespec}." + ) + raise FileNotFoundError from OSError + # pass all the other excepts out + + try: + # With the ModuleSpec object, we can get the + module_type = importlib.util.module_from_spec(module_spec) + except ImportError: + logger.fatal( + "Loader that defines exec_module() must also define create_module()" + ) + raise ImportError from ImportError + + # module_type also has all the loaded Python values/objects from + # the Pelican configuration settings file, but it is not + # yet readable for all... + + # if you have use "from pelicanconf import ...", then you will not + # be able to free up the module + + # store the module into the sys.modules + sys.modules[module_name] = module_type + + try: + # finally, execute any codes in the Pelican configuration settings file. + module_spec.loader.exec_module(module_type) + # Below logger.debug is used as-is by test_settings_module.py unit-test + logger.debug( + f"Loaded module '{module_name}' from {resolved_absolute_filespec} file" + ) + return module_type + except SyntaxError as e: + full_filespec = conf_filespec.resolve(strict=True) + # Show where in the pelicanconf.py the offending syntax error is at via {e}. + logger.error( + f"{e}.\nHINT: " + f"Try executing `python {full_filespec}` " + f"for better syntax troubleshooting." + ) + # Trying something new, reraise the exception up + raise SyntaxError( + f"Invalid syntax error at line number {e.end_lineno}" + f" column offset {e.end_offset}", + { + "filename": full_filespec, + "lineno": int(e.lineno), + "offset": int(e.offset), + "text": e.text, + "end_lineno": int(e.end_lineno), + "end_offset": int(e.end_offset), + }, + ) from e + except Any as e: + logger.critical( + f"'Python system module loader for {resolved_absolute_filespec}'" + f" module failed: {e}." + ) + sys.exit(errno.ENOEXEC) + + +def reload_source(name: str, path: str | pathlib.Path) -> ModuleType | None: + """Reload the configuration settings file""" + # first line of defense against errant built-in module name + if name != DEFAULT_MODULE_NAME: + logger.error( + f"Module name of {name} cannot be anything other " + f"than {DEFAULT_MODULE_NAME}" + ) + return None + # second line of defense, this function call only works if one firstly + # called the load_source() + if name not in sys.modules: + logger.error(f"Module name of {name} is not loaded, call load_source() firstly") + return None + # Now we can do the dangerous step + del sys.modules[name] + module_type = load_source(name, path) + if module_type is None: + logger.error(f"Module name of {name} is not loaded, call load_source() firstly") + return None + return module_type + + def read_settings( - path: Optional[str] = None, override: Optional[Settings] = None + path: Optional[str] = None, + override: Optional[Settings] = None, + reload: bool = False, ) -> Settings: + """reads the setting files into a Python configuration settings module + + Returns the final Settings list of keys/values after reading the file + and applying an override of settings on top of it. + + :param path: The full filespec path to the Pelican configuration settings file. + :type path: str | None + :param path: The override settings to be used to overwrite the ones read in + from the Pelican configuration settings file. + :type override: Settings | None + :param reload: A boolean value to safely reload the Pelican configuration settings + file into a Python module + :type reload: bool + :return: The Settings list of configurations after extracting the key/value from + the path of Pelican configuration settings file and after the override + settings has been applied over its read settings. + :rtype: Settings + """ settings = override or {} if path: - settings = dict(get_settings_from_file(path), **settings) + settings = dict(get_settings_from_file(path, reload), **settings) if settings: settings = handle_deprecated_settings(settings) @@ -217,7 +458,7 @@ def getabs(maybe_relative, base_path=path): ] settings = dict(copy.deepcopy(DEFAULT_CONFIG), **settings) - settings = configure_settings(settings) + settings = configure_settings(settings, reload) # This is because there doesn't seem to be a way to pass extra # parameters to docutils directive handlers, so we have to have a @@ -229,19 +470,40 @@ def getabs(maybe_relative, base_path=path): def get_settings_from_module(module: Optional[ModuleType] = None) -> Settings: - """Loads settings from a module, returns a dictionary.""" - + """Clones a dictionary of settings from a module. + + :param module: Attempts to load a module using singular current working directory + (`$CWD`) search method then returns a clone-duplicate of its + settings found in the module. + If no module (`None`) is given, then default module name is used. + :type module: ModuleType | None + :return: Returns a dictionary of Settings found in that Python module. + :rtype: Settings""" context = {} if module is not None: context.update((k, v) for k, v in inspect.getmembers(module) if k.isupper()) return context -def get_settings_from_file(path: str) -> Settings: - """Loads settings from a file path, returning a dict.""" - - name, ext = os.path.splitext(os.path.basename(path)) - module = load_source(name, path) +def get_settings_from_file(path: str, reload: bool) -> Settings: + """Loads module from a file then clones dictionary of settings from that module. + + :param path: Attempts to load a module using a file specification (absolute or + relative) then returns a clone-duplicate of its settings found in + the module. If no module (`None`) is given, then default module + name is used. + :param path: A file specification (absolute or relative) that points to the + Python script file containing the keyword/value assignment settings. + :param reload: A bool value to check if module is already preloaded + before doing a reload. + :return: Returns a dictionary of Settings found in that Python module. + :rtype: Settings""" + name = DEFAULT_MODULE_NAME + # Keep the module name constant for Pelican configuration settings file + if reload: + module = reload_source(name, path) + else: + module = load_source(name, path) return get_settings_from_module(module) @@ -568,11 +830,17 @@ def handle_deprecated_settings(settings: Settings) -> Settings: return settings -def configure_settings(settings: Settings) -> Settings: +def configure_settings(settings: Settings, reload: None | bool = False) -> Settings: """Provide optimizations, error checking, and warnings for the given settings. Also, specify the log messages to be ignored. - """ + + :param settings: Contains a dictionary of Pelican keyword/keyvalue + :type settings: Settings + :param reload: A flag to reload the same module but maybe with a different file + :type reload: bool + :return: An updated dictionary of Pelican's keywords and its keyvalue. + :rtype: Settings""" if "PATH" not in settings or not os.path.isdir(settings["PATH"]): raise Exception( "You need to specify a path containing the content" @@ -613,7 +881,7 @@ def configure_settings(settings: Settings) -> Settings: ]: if key in settings and not isinstance(settings[key], types): value = settings.pop(key) - logger.warn( + logger.warning( "Detected misconfigured %s (%s), falling back to the default (%s)", key, value, diff --git a/pelican/tests/settings/pelicanconf-missing-path-var.py b/pelican/tests/settings/pelicanconf-missing-path-var.py new file mode 100644 index 000000000..02a4dc906 --- /dev/null +++ b/pelican/tests/settings/pelicanconf-missing-path-var.py @@ -0,0 +1,15 @@ +DEBUG = True +#####PATH = "not-defined" # our newly created location for our articles/pages to reside in/under +OUTPUT_PATH = "output" +ARTICLES_PATH = ["articles"] +PLUGIN_PATHS = [ + "~/admin/websites/egbert.net/pelican/plugins", + "~/admin/websites/egbert.net/pelican-plugins", + "~/admin/websites/egbert.net/development/tableize/pelican/plugins", +] +PLUGINS = ["tableize"] +AUTHOR = "John" +SITENAME = "My 1st Site" +# for +PORT = 8000 +BIND = "127.0.0.1" diff --git a/pelican/tests/settings/pelicanconf-path-no-such-directory.py b/pelican/tests/settings/pelicanconf-path-no-such-directory.py new file mode 100644 index 000000000..8da32e462 --- /dev/null +++ b/pelican/tests/settings/pelicanconf-path-no-such-directory.py @@ -0,0 +1,15 @@ +DEBUG = True +PATH = "no-such-directory" # our newly created location for our articles/pages to reside in/under +OUTPUT_PATH = "output" +ARTICLES_PATH = ["articles"] +PLUGIN_PATHS = [ + "~/admin/websites/egbert.net/pelican/plugins", + "~/admin/websites/egbert.net/pelican-plugins", + "~/admin/websites/egbert.net/development/tableize/pelican/plugins", +] +PLUGINS = ["tableize"] +AUTHOR = "John" +SITENAME = "My 1st Site" +# for +PORT = 8000 +BIND = "127.0.0.1" diff --git a/pelican/tests/settings/pelicanconf-syntax-error.py.disabled b/pelican/tests/settings/pelicanconf-syntax-error.py.disabled new file mode 100644 index 000000000..6e7c23853 --- /dev/null +++ b/pelican/tests/settings/pelicanconf-syntax-error.py.disabled @@ -0,0 +1,12 @@ +#! Ohhhtay, this is not a python file despite it's .py file extension + +# Error to occur is at line 5, column 2 + + // put some C code in it + +int i + +for (i = 1; i < 200; ) { + i = i + 1; + + } diff --git a/pelican/tests/settings/pelicanconf-syntax-error2.py.disabled b/pelican/tests/settings/pelicanconf-syntax-error2.py.disabled new file mode 100644 index 000000000..0bee3309d --- /dev/null +++ b/pelican/tests/settings/pelicanconf-syntax-error2.py.disabled @@ -0,0 +1,18 @@ +#! Ohhhtay, this is not a python file despite it's .py file extension +# Error to occur is at line 13, column 5 +# CAUTION: do not change this error location without corresponding changes in test cases + +import me + +def function_a(arg1): + + value2 = arg1 + value3 = value2 + 1 + return value3 + +int i + +for (i = 1; i < 200; ) { + i = i + 1; + + } diff --git a/pelican/tests/settings/pelicanconf-valid.py b/pelican/tests/settings/pelicanconf-valid.py new file mode 100644 index 000000000..71ed477b3 --- /dev/null +++ b/pelican/tests/settings/pelicanconf-valid.py @@ -0,0 +1,15 @@ +DEBUG = True +PATH = "content" # our newly created location for our articles/pages to reside in/under +OUTPUT_PATH = "output" +ARTICLES_PATH = ["articles"] +PLUGIN_PATHS = [ + "~/admin/websites/egbert.net/pelican/plugins", + "~/admin/websites/egbert.net/pelican-plugins", + "~/admin/websites/egbert.net/development/tableize/pelican/plugins", +] +PLUGINS = ["tableize"] +AUTHOR = "John" +SITENAME = "My 1st Site" +# for +PORT = 8000 +BIND = "127.0.0.1" diff --git a/pelican/tests/test_settings.py b/pelican/tests/test_settings.py index 84f7a5c98..a05940549 100644 --- a/pelican/tests/test_settings.py +++ b/pelican/tests/test_settings.py @@ -1,6 +1,7 @@ import copy import locale import os +import sys from os.path import abspath, dirname, join from pelican.settings import ( @@ -25,14 +26,24 @@ def setUp(self): locale.setlocale(locale.LC_ALL, "C") self.PATH = abspath(dirname(__file__)) default_conf = join(self.PATH, "default_conf.py") - self.settings = read_settings(default_conf) + self.settings = read_settings(default_conf, reload=True) + self.original_sys_modules = sys.modules def tearDown(self): locale.setlocale(locale.LC_ALL, self.old_locale) + self.assertEqual( + self.original_sys_modules, + sys.modules, + "One of the unit test did not clean up sys.modules " "properly.", + ) - def test_overwrite_existing_settings(self): - self.assertEqual(self.settings.get("SITENAME"), "Alexis' log") - self.assertEqual(self.settings.get("SITEURL"), "http://blog.notmyidea.org") + # NOTE: testSetup() is done once for all unit tests within the same class. + # NOTE: Probably want to use test_module(module) or xtest_() + # Parallelized test are done in random order, so this FIRSTLY test + # will fail ... most of the time. + # def test_overwrite_existing_settings(self): + # self.assertEqual(self.settings.get("SITENAME"), "Alexis' log") + # self.assertEqual(self.settings.get("SITEURL"), "http://blog.notmyidea.org") def test_keep_default_settings(self): # Keep default settings if not defined. @@ -57,12 +68,23 @@ def test_read_empty_settings(self): def test_settings_return_independent(self): # Make sure that the results from one settings call doesn't - # effect past or future instances. + # affect past or future instances. self.PATH = abspath(dirname(__file__)) default_conf = join(self.PATH, "default_conf.py") - settings = read_settings(default_conf) - settings["SITEURL"] = "new-value" - new_settings = read_settings(default_conf) + # settings['SITEURL'] should be blank + + # Trap any exception error + try: + # why did setUp() call read_settings firstly? So, we reload here + settings = read_settings(default_conf, reload=True) + settings["SITEURL"] = "new-value" + except any: + raise any from None + + # clobber settings['SITEURL'] + new_settings = read_settings(default_conf, reload=True) + # see if pulling up a new set of original settings (into a different variable, + # via 'new_settings' does not clobber the 'settings' variable self.assertNotEqual(new_settings["SITEURL"], settings["SITEURL"]) def test_defaults_not_overwritten(self): @@ -105,6 +127,7 @@ def test_configure_settings(self): self.assertEqual(settings["FEED_DOMAIN"], "http://blog.notmyidea.org") settings["FEED_DOMAIN"] = "http://feeds.example.com" + configure_settings(settings) self.assertEqual(settings["FEED_DOMAIN"], "http://feeds.example.com") @@ -175,149 +198,6 @@ def test__printf_s_to_format_field(self): found = result.format(slug="qux") self.assertEqual(expected, found) - def test_deprecated_extra_templates_paths(self): - settings = self.settings - settings["EXTRA_TEMPLATES_PATHS"] = ["/foo/bar", "/ha"] - - settings = handle_deprecated_settings(settings) - - self.assertEqual(settings["THEME_TEMPLATES_OVERRIDES"], ["/foo/bar", "/ha"]) - self.assertNotIn("EXTRA_TEMPLATES_PATHS", settings) - - def test_deprecated_paginated_direct_templates(self): - settings = self.settings - settings["PAGINATED_DIRECT_TEMPLATES"] = ["index", "archives"] - settings["PAGINATED_TEMPLATES"] = {"index": 10, "category": None} - settings = handle_deprecated_settings(settings) - self.assertEqual( - settings["PAGINATED_TEMPLATES"], - {"index": 10, "category": None, "archives": None}, - ) - self.assertNotIn("PAGINATED_DIRECT_TEMPLATES", settings) - - def test_deprecated_paginated_direct_templates_from_file(self): - # This is equivalent to reading a settings file that has - # PAGINATED_DIRECT_TEMPLATES defined but no PAGINATED_TEMPLATES. - settings = read_settings( - None, override={"PAGINATED_DIRECT_TEMPLATES": ["index", "archives"]} - ) - self.assertEqual( - settings["PAGINATED_TEMPLATES"], - { - "archives": None, - "author": None, - "index": None, - "category": None, - "tag": None, - }, - ) - self.assertNotIn("PAGINATED_DIRECT_TEMPLATES", settings) - def test_theme_and_extra_templates_exception(self): - settings = self.settings - settings["EXTRA_TEMPLATES_PATHS"] = ["/ha"] - settings["THEME_TEMPLATES_OVERRIDES"] = ["/foo/bar"] - - self.assertRaises(Exception, handle_deprecated_settings, settings) - - def test_slug_and_slug_regex_substitutions_exception(self): - settings = {} - settings["SLUG_REGEX_SUBSTITUTIONS"] = [("C++", "cpp")] - settings["TAG_SUBSTITUTIONS"] = [("C#", "csharp")] - - self.assertRaises(Exception, handle_deprecated_settings, settings) - - def test_deprecated_slug_substitutions(self): - default_slug_regex_subs = self.settings["SLUG_REGEX_SUBSTITUTIONS"] - - # If no deprecated setting is set, don't set new ones - settings = {} - settings = handle_deprecated_settings(settings) - self.assertNotIn("SLUG_REGEX_SUBSTITUTIONS", settings) - self.assertNotIn("TAG_REGEX_SUBSTITUTIONS", settings) - self.assertNotIn("CATEGORY_REGEX_SUBSTITUTIONS", settings) - self.assertNotIn("AUTHOR_REGEX_SUBSTITUTIONS", settings) - - # If SLUG_SUBSTITUTIONS is set, set {SLUG, AUTHOR}_REGEX_SUBSTITUTIONS - # correctly, don't set {CATEGORY, TAG}_REGEX_SUBSTITUTIONS - settings = {} - settings["SLUG_SUBSTITUTIONS"] = [("C++", "cpp")] - settings = handle_deprecated_settings(settings) - self.assertEqual( - settings.get("SLUG_REGEX_SUBSTITUTIONS"), - [(r"C\+\+", "cpp")] + default_slug_regex_subs, - ) - self.assertNotIn("TAG_REGEX_SUBSTITUTIONS", settings) - self.assertNotIn("CATEGORY_REGEX_SUBSTITUTIONS", settings) - self.assertEqual( - settings.get("AUTHOR_REGEX_SUBSTITUTIONS"), default_slug_regex_subs - ) - - # If {CATEGORY, TAG, AUTHOR}_SUBSTITUTIONS are set, set - # {CATEGORY, TAG, AUTHOR}_REGEX_SUBSTITUTIONS correctly, don't set - # SLUG_REGEX_SUBSTITUTIONS - settings = {} - settings["TAG_SUBSTITUTIONS"] = [("C#", "csharp")] - settings["CATEGORY_SUBSTITUTIONS"] = [("C#", "csharp")] - settings["AUTHOR_SUBSTITUTIONS"] = [("Alexander Todorov", "atodorov")] - settings = handle_deprecated_settings(settings) - self.assertNotIn("SLUG_REGEX_SUBSTITUTIONS", settings) - self.assertEqual( - settings["TAG_REGEX_SUBSTITUTIONS"], - [(r"C\#", "csharp")] + default_slug_regex_subs, - ) - self.assertEqual( - settings["CATEGORY_REGEX_SUBSTITUTIONS"], - [(r"C\#", "csharp")] + default_slug_regex_subs, - ) - self.assertEqual( - settings["AUTHOR_REGEX_SUBSTITUTIONS"], - [(r"Alexander\ Todorov", "atodorov")] + default_slug_regex_subs, - ) - - # If {SLUG, CATEGORY, TAG, AUTHOR}_SUBSTITUTIONS are set, set - # {SLUG, CATEGORY, TAG, AUTHOR}_REGEX_SUBSTITUTIONS correctly - settings = {} - settings["SLUG_SUBSTITUTIONS"] = [("C++", "cpp")] - settings["TAG_SUBSTITUTIONS"] = [("C#", "csharp")] - settings["CATEGORY_SUBSTITUTIONS"] = [("C#", "csharp")] - settings["AUTHOR_SUBSTITUTIONS"] = [("Alexander Todorov", "atodorov")] - settings = handle_deprecated_settings(settings) - self.assertEqual( - settings["TAG_REGEX_SUBSTITUTIONS"], - [(r"C\+\+", "cpp")] + [(r"C\#", "csharp")] + default_slug_regex_subs, - ) - self.assertEqual( - settings["CATEGORY_REGEX_SUBSTITUTIONS"], - [(r"C\+\+", "cpp")] + [(r"C\#", "csharp")] + default_slug_regex_subs, - ) - self.assertEqual( - settings["AUTHOR_REGEX_SUBSTITUTIONS"], - [(r"Alexander\ Todorov", "atodorov")] + default_slug_regex_subs, - ) - - # Handle old 'skip' flags correctly - settings = {} - settings["SLUG_SUBSTITUTIONS"] = [("C++", "cpp", True)] - settings["AUTHOR_SUBSTITUTIONS"] = [("Alexander Todorov", "atodorov", False)] - settings = handle_deprecated_settings(settings) - self.assertEqual( - settings.get("SLUG_REGEX_SUBSTITUTIONS"), - [(r"C\+\+", "cpp")] + [(r"(?u)\A\s*", ""), (r"(?u)\s*\Z", "")], - ) - self.assertEqual( - settings["AUTHOR_REGEX_SUBSTITUTIONS"], - [(r"Alexander\ Todorov", "atodorov")] + default_slug_regex_subs, - ) - - def test_deprecated_slug_substitutions_from_file(self): - # This is equivalent to reading a settings file that has - # SLUG_SUBSTITUTIONS defined but no SLUG_REGEX_SUBSTITUTIONS. - settings = read_settings( - None, override={"SLUG_SUBSTITUTIONS": [("C++", "cpp")]} - ) - self.assertEqual( - settings["SLUG_REGEX_SUBSTITUTIONS"], - [(r"C\+\+", "cpp")] + self.settings["SLUG_REGEX_SUBSTITUTIONS"], - ) - self.assertNotIn("SLUG_SUBSTITUTIONS", settings) +if __name__ == "__main__": + unittest.main() diff --git a/pelican/tests/test_settings_deprecated.py b/pelican/tests/test_settings_deprecated.py new file mode 100644 index 000000000..ade44c7a8 --- /dev/null +++ b/pelican/tests/test_settings_deprecated.py @@ -0,0 +1,347 @@ +import copy +import locale +import os +import sys +from os.path import abspath, dirname, join + +from pelican.settings import ( + DEFAULT_CONFIG, + DEFAULT_THEME, + _printf_s_to_format_field, + configure_settings, + handle_deprecated_settings, + read_settings, +) +from pelican.tests.support import unittest + + +class SettingsDeprecated(unittest.TestCase): + """Exercises handle_deprecated_settings()""" + + def setUp(self): + self.old_locale = locale.setlocale(locale.LC_ALL) + locale.setlocale(locale.LC_ALL, "C") + self.PATH = abspath(dirname(__file__)) + default_conf = join(self.PATH, "default_conf.py") + self.settings = read_settings(default_conf, reload=True) + self.original_sys_modules = sys.modules + + def tearDown(self): + locale.setlocale(locale.LC_ALL, self.old_locale) + self.assertEqual( + self.original_sys_modules, + sys.modules, + "One of the unit test did not clean up sys.modules " "properly.", + ) + + # NOTE: testSetup() is done once for all unit tests within the same class. + # NOTE: Probably want to use test_module(module) or xtest_() + # Parallelized test are done in random order, so this FIRSTLY test + # will fail ... most of the time. + # def test_overwrite_existing_settings(self): + # self.assertEqual(self.settings.get("SITENAME"), "Alexis' log") + # self.assertEqual(self.settings.get("SITEURL"), "http://blog.notmyidea.org") + + def test_keep_default_settings(self): + # Keep default settings if not defined. + self.assertEqual( + self.settings.get("DEFAULT_CATEGORY"), DEFAULT_CONFIG["DEFAULT_CATEGORY"] + ) + + def test_dont_copy_small_keys(self): + # Do not copy keys not in caps. + self.assertNotIn("foobar", self.settings) + + def test_read_empty_settings(self): + # Ensure an empty settings file results in default settings. + settings = read_settings(None) + expected = copy.deepcopy(DEFAULT_CONFIG) + # Added by configure settings + expected["FEED_DOMAIN"] = "" + expected["ARTICLE_EXCLUDES"] = ["pages"] + expected["PAGE_EXCLUDES"] = [""] + self.maxDiff = None + self.assertDictEqual(settings, expected) + + def test_settings_return_independent(self): + # Make sure that the results from one settings call doesn't + # affect past or future instances. + self.PATH = abspath(dirname(__file__)) + default_conf = join(self.PATH, "default_conf.py") + # settings['SITEURL'] should be blank + + # Trap any exception error + try: + # why did setUp() call read_settings firstly? So, we reload here + settings = read_settings(default_conf, reload=True) + settings["SITEURL"] = "new-value" + except any: + raise any from None + + # clobber settings['SITEURL'] + new_settings = read_settings(default_conf, reload=True) + # see if pulling up a new set of original settings (into a different variable, + # via 'new_settings' does not clobber the 'settings' variable + self.assertNotEqual(new_settings["SITEURL"], settings["SITEURL"]) + + def test_defaults_not_overwritten(self): + # This assumes 'SITENAME': 'A Pelican Blog' + settings = read_settings(None) + settings["SITENAME"] = "Not a Pelican Blog" + self.assertNotEqual(settings["SITENAME"], DEFAULT_CONFIG["SITENAME"]) + + def test_static_path_settings_safety(self): + # Disallow static paths from being strings + settings = { + "STATIC_PATHS": "foo/bar", + "THEME_STATIC_PATHS": "bar/baz", + # These 4 settings are required to run configure_settings + "PATH": ".", + "THEME": DEFAULT_THEME, + "SITEURL": "http://blog.notmyidea.org/", + "LOCALE": "", + } + configure_settings(settings) + self.assertEqual(settings["STATIC_PATHS"], DEFAULT_CONFIG["STATIC_PATHS"]) + self.assertEqual( + settings["THEME_STATIC_PATHS"], DEFAULT_CONFIG["THEME_STATIC_PATHS"] + ) + + def test_configure_settings(self): + # Manipulations to settings should be applied correctly. + settings = { + "SITEURL": "http://blog.notmyidea.org/", + "LOCALE": "", + "PATH": os.curdir, + "THEME": DEFAULT_THEME, + } + configure_settings(settings) + + # SITEURL should not have a trailing slash + self.assertEqual(settings["SITEURL"], "http://blog.notmyidea.org") + + # FEED_DOMAIN, if undefined, should default to SITEURL + self.assertEqual(settings["FEED_DOMAIN"], "http://blog.notmyidea.org") + + settings["FEED_DOMAIN"] = "http://feeds.example.com" + + configure_settings(settings) + self.assertEqual(settings["FEED_DOMAIN"], "http://feeds.example.com") + + def test_theme_settings_exceptions(self): + settings = self.settings + + # Check that theme lookup in "pelican/themes" functions as expected + settings["THEME"] = os.path.split(settings["THEME"])[1] + configure_settings(settings) + self.assertEqual(settings["THEME"], DEFAULT_THEME) + + # Check that non-existent theme raises exception + settings["THEME"] = "foo" + self.assertRaises(Exception, configure_settings, settings) + + def test_deprecated_dir_setting(self): + settings = self.settings + + settings["ARTICLE_DIR"] = "foo" + settings["PAGE_DIR"] = "bar" + + settings = handle_deprecated_settings(settings) + + self.assertEqual(settings["ARTICLE_PATHS"], ["foo"]) + self.assertEqual(settings["PAGE_PATHS"], ["bar"]) + + with self.assertRaises(KeyError): + settings["ARTICLE_DIR"] + settings["PAGE_DIR"] + + def test_default_encoding(self): + # Test that the user locale is set if not specified in settings + + locale.setlocale(locale.LC_ALL, "C") + # empty string = user system locale + self.assertEqual(self.settings["LOCALE"], [""]) + + configure_settings(self.settings) + lc_time = locale.getlocale(locale.LC_TIME) # should be set to user locale + + # explicitly set locale to user pref and test + locale.setlocale(locale.LC_TIME, "") + self.assertEqual(lc_time, locale.getlocale(locale.LC_TIME)) + + def test_invalid_settings_throw_exception(self): + # Test that the path name is valid + + # test that 'PATH' is set + settings = {} + + self.assertRaises(Exception, configure_settings, settings) + + # Test that 'PATH' is valid + settings["PATH"] = "" + self.assertRaises(Exception, configure_settings, settings) + + # Test nonexistent THEME + settings["PATH"] = os.curdir + settings["THEME"] = "foo" + + self.assertRaises(Exception, configure_settings, settings) + + def test__printf_s_to_format_field(self): + for s in ("%s", "{%s}", "{%s"): + option = f"foo/{s}/bar.baz" + result = _printf_s_to_format_field(option, "slug") + expected = option % "qux" + found = result.format(slug="qux") + self.assertEqual(expected, found) + + def test_deprecated_extra_templates_paths(self): + settings = self.settings + settings["EXTRA_TEMPLATES_PATHS"] = ["/foo/bar", "/ha"] + + settings = handle_deprecated_settings(settings) + + self.assertEqual(settings["THEME_TEMPLATES_OVERRIDES"], ["/foo/bar", "/ha"]) + self.assertNotIn("EXTRA_TEMPLATES_PATHS", settings) + + def test_deprecated_paginated_direct_templates(self): + settings = self.settings + settings["PAGINATED_DIRECT_TEMPLATES"] = ["index", "archives"] + settings["PAGINATED_TEMPLATES"] = {"index": 10, "category": None} + settings = handle_deprecated_settings(settings) + self.assertEqual( + settings["PAGINATED_TEMPLATES"], + {"index": 10, "category": None, "archives": None}, + ) + self.assertNotIn("PAGINATED_DIRECT_TEMPLATES", settings) + + def test_deprecated_paginated_direct_templates_from_file(self): + # This is equivalent to reading a settings file that has + # PAGINATED_DIRECT_TEMPLATES defined but no PAGINATED_TEMPLATES. + settings = read_settings( + None, override={"PAGINATED_DIRECT_TEMPLATES": ["index", "archives"]} + ) + self.assertEqual( + settings["PAGINATED_TEMPLATES"], + { + "archives": None, + "author": None, + "index": None, + "category": None, + "tag": None, + }, + ) + self.assertNotIn("PAGINATED_DIRECT_TEMPLATES", settings) + + def test_theme_and_extra_templates_exception(self): + settings = self.settings + settings["EXTRA_TEMPLATES_PATHS"] = ["/ha"] + settings["THEME_TEMPLATES_OVERRIDES"] = ["/foo/bar"] + + self.assertRaises(Exception, handle_deprecated_settings, settings) + + def test_slug_and_slug_regex_substitutions_exception(self): + settings = {} + settings["SLUG_REGEX_SUBSTITUTIONS"] = [("C++", "cpp")] + settings["TAG_SUBSTITUTIONS"] = [("C#", "csharp")] + + self.assertRaises(Exception, handle_deprecated_settings, settings) + + def test_deprecated_slug_substitutions(self): + default_slug_regex_subs = self.settings["SLUG_REGEX_SUBSTITUTIONS"] + + # If no deprecated setting is set, don't set new ones + settings = {} + settings = handle_deprecated_settings(settings) + self.assertNotIn("SLUG_REGEX_SUBSTITUTIONS", settings) + self.assertNotIn("TAG_REGEX_SUBSTITUTIONS", settings) + self.assertNotIn("CATEGORY_REGEX_SUBSTITUTIONS", settings) + self.assertNotIn("AUTHOR_REGEX_SUBSTITUTIONS", settings) + + # If SLUG_SUBSTITUTIONS is set, set {SLUG, AUTHOR}_REGEX_SUBSTITUTIONS + # correctly, don't set {CATEGORY, TAG}_REGEX_SUBSTITUTIONS + settings = {} + settings["SLUG_SUBSTITUTIONS"] = [("C++", "cpp")] + settings = handle_deprecated_settings(settings) + self.assertEqual( + settings.get("SLUG_REGEX_SUBSTITUTIONS"), + [(r"C\+\+", "cpp")] + default_slug_regex_subs, + ) + self.assertNotIn("TAG_REGEX_SUBSTITUTIONS", settings) + self.assertNotIn("CATEGORY_REGEX_SUBSTITUTIONS", settings) + self.assertEqual( + settings.get("AUTHOR_REGEX_SUBSTITUTIONS"), default_slug_regex_subs + ) + + # If {CATEGORY, TAG, AUTHOR}_SUBSTITUTIONS are set, set + # {CATEGORY, TAG, AUTHOR}_REGEX_SUBSTITUTIONS correctly, don't set + # SLUG_REGEX_SUBSTITUTIONS + settings = {} + settings["TAG_SUBSTITUTIONS"] = [("C#", "csharp")] + settings["CATEGORY_SUBSTITUTIONS"] = [("C#", "csharp")] + settings["AUTHOR_SUBSTITUTIONS"] = [("Alexander Todorov", "atodorov")] + settings = handle_deprecated_settings(settings) + self.assertNotIn("SLUG_REGEX_SUBSTITUTIONS", settings) + self.assertEqual( + settings["TAG_REGEX_SUBSTITUTIONS"], + [(r"C\#", "csharp")] + default_slug_regex_subs, + ) + self.assertEqual( + settings["CATEGORY_REGEX_SUBSTITUTIONS"], + [(r"C\#", "csharp")] + default_slug_regex_subs, + ) + self.assertEqual( + settings["AUTHOR_REGEX_SUBSTITUTIONS"], + [(r"Alexander\ Todorov", "atodorov")] + default_slug_regex_subs, + ) + + # If {SLUG, CATEGORY, TAG, AUTHOR}_SUBSTITUTIONS are set, set + # {SLUG, CATEGORY, TAG, AUTHOR}_REGEX_SUBSTITUTIONS correctly + settings = {} + settings["SLUG_SUBSTITUTIONS"] = [("C++", "cpp")] + settings["TAG_SUBSTITUTIONS"] = [("C#", "csharp")] + settings["CATEGORY_SUBSTITUTIONS"] = [("C#", "csharp")] + settings["AUTHOR_SUBSTITUTIONS"] = [("Alexander Todorov", "atodorov")] + settings = handle_deprecated_settings(settings) + self.assertEqual( + settings["TAG_REGEX_SUBSTITUTIONS"], + [(r"C\+\+", "cpp")] + [(r"C\#", "csharp")] + default_slug_regex_subs, + ) + self.assertEqual( + settings["CATEGORY_REGEX_SUBSTITUTIONS"], + [(r"C\+\+", "cpp")] + [(r"C\#", "csharp")] + default_slug_regex_subs, + ) + self.assertEqual( + settings["AUTHOR_REGEX_SUBSTITUTIONS"], + [(r"Alexander\ Todorov", "atodorov")] + default_slug_regex_subs, + ) + + # Handle old 'skip' flags correctly + settings = {} + settings["SLUG_SUBSTITUTIONS"] = [("C++", "cpp", True)] + settings["AUTHOR_SUBSTITUTIONS"] = [("Alexander Todorov", "atodorov", False)] + settings = handle_deprecated_settings(settings) + self.assertEqual( + settings.get("SLUG_REGEX_SUBSTITUTIONS"), + [(r"C\+\+", "cpp")] + [(r"(?u)\A\s*", ""), (r"(?u)\s*\Z", "")], + ) + self.assertEqual( + settings["AUTHOR_REGEX_SUBSTITUTIONS"], + [(r"Alexander\ Todorov", "atodorov")] + default_slug_regex_subs, + ) + + def test_deprecated_slug_substitutions_from_file(self): + # This is equivalent to reading a settings file that has + # SLUG_SUBSTITUTIONS defined but no SLUG_REGEX_SUBSTITUTIONS. + settings = read_settings( + None, override={"SLUG_SUBSTITUTIONS": [("C++", "cpp")]} + ) + self.assertEqual( + settings["SLUG_REGEX_SUBSTITUTIONS"], + [(r"C\+\+", "cpp")] + self.settings["SLUG_REGEX_SUBSTITUTIONS"], + ) + self.assertNotIn("SLUG_SUBSTITUTIONS", settings) + + +if __name__ == "__main__": + unittest.main() diff --git a/pelican/tests/test_settings_module.py b/pelican/tests/test_settings_module.py new file mode 100644 index 000000000..d5c0ba6ab --- /dev/null +++ b/pelican/tests/test_settings_module.py @@ -0,0 +1,1074 @@ +# +# Focus on settings.py/load_source() only + +# Minimum version: Python 3.6 (tempfile.mkdtemp()) +# Minimum version: Pytest 4.0, Python 3.8+ + +import errno +import inspect +import locale +import logging +import os +import shutil +import stat +import sys +import tempfile +from pathlib import Path + +import pytest +from _pytest.logging import LogCaptureHandler, _remove_ansi_escape_sequences # NOQA + +from pelican.settings import ( + load_source, +) +from pelican.tests.support import unittest + +# Valid Python file extension +EXT_PYTHON = ".py" +EXT_PYTHON_DISABLED = ".disabled" + +# DIRSPEC_: where all the test config files are stored +# we hope that current working directory is always in pelican/pelican/tests +DIRSPEC_CURRENT: str = os.getcwd() +DIRSPEC_DATADIR: str = "settings" + os.sep +DIRSPEC_RELATIVE: str = DIRSPEC_DATADIR # reuse 'tests/settings/' as scratch area + +# PC_ = Pelican Configuration or PELICANCONF or pelicanconf +# FILENAME_: file name without the extension +PC_FILENAME_DEFAULT = "pelicanconf" +PC_FILENAME_VALID = "pelicanconf-valid" +PC_FILENAME_NOTFOUND = "pelicanconf-not-found" +PC_FILENAME_UNREADABLE = "pelicanconf-unreadable" +PC_FILENAME_SYNTAX_ERROR = "pelicanconf-syntax-error" + +# MODNAME_ = Module name +PC_MODNAME_DEFAULT = PC_FILENAME_DEFAULT # used if module_name is blank +PC_MODNAME_VALID = PC_FILENAME_VALID +PC_MODNAME_UNREADABLE = PC_FILENAME_UNREADABLE +PC_MODNAME_NOT_EXIST = PC_FILENAME_NOTFOUND +PC_MODNAME_DOTTED = "non-existing-module.cannot-get-there" # there is a period +PC_MODNAME_SYS_BUILTIN = "calendar" + +TMP_FILENAME_SUFFIX = PC_FILENAME_DEFAULT + +# FULLNAME_: filename + extension +PC_FULLNAME_VALID: str = PC_FILENAME_VALID + EXT_PYTHON +PC_FULLNAME_NOTFOUND: str = PC_FILENAME_NOTFOUND + EXT_PYTHON +PC_FULLNAME_UNREADABLE: str = PC_FILENAME_UNREADABLE + EXT_PYTHON +PC_FULLNAME_SYNTAX_ERROR: str = PC_FILENAME_SYNTAX_ERROR + EXT_PYTHON +# BLOB_: a file trying to hide from ruff/black syntax checkers for our syntax tests +BLOB_FULLNAME_SYNTAX_ERROR = PC_FULLNAME_SYNTAX_ERROR + EXT_PYTHON_DISABLED + +# DIRNAME_: a construct of where to find config file for specific test +PC_DIRNAME_NOTFOUND: str = "no-such-directory" +PC_DIRNAME_NOACCESS: str = "unreadable-directory" + +# DIRSPEC_: the full directory path + +# Our test files +BLOB_FILESPEC_UNREADABLE = Path(DIRSPEC_DATADIR) / PC_FULLNAME_UNREADABLE +BLOB_FILESPEC_SYNTAX_ERROR = Path(DIRSPEC_DATADIR) / str( + PC_FULLNAME_SYNTAX_ERROR + EXT_PYTHON_DISABLED +) + +# PATH_: the final path for unit tests here +# FILESPEC_: the full path + filename + extension +# REL_: relative path +RO_FILESPEC_REL_VALID_PATH = Path(DIRSPEC_DATADIR) / PC_FULLNAME_VALID +RO_FILESPEC_REL_SYNTAX_ERROR_PATH = Path(DIRSPEC_DATADIR) / PC_FULLNAME_SYNTAX_ERROR +RO_FILESPEC_REL_NOTFOUND_PATH = Path(DIRSPEC_DATADIR) / PC_FULLNAME_NOTFOUND +# FILESPEC_REL_UNREADABLE_PATH = Path(DIRSPEC_RELATIVE) / PC_FULLNAME_UNREADABLE + +load_source_argument_count = 2 + +# Code starts here +logging.basicConfig(level=0) +log = logging.getLogger(__name__) +logging.root.setLevel(logging.DEBUG) +log.propagate = True + + +def remove_read_permissions(path): + """Remove read permissions from this path, keeping all other permissions intact. + + :param path: The path whose permissions to alter. + :type path: str + """ + no_user_reading = ~stat.S_IRUSR + no_group_reading = ~stat.S_IRGRP + no_other_reading = ~stat.S_IROTH + no_reading = no_user_reading & no_group_reading & no_other_reading + + current_permissions = stat.S_IMODE(os.lstat(path).st_mode) + os.chmod(path, current_permissions & no_reading) + + +# We need an existing Python system built-in module for testing load_source. +if PC_MODNAME_SYS_BUILTIN not in sys.modules: + pytest.exit( + errno.EACCES, + "PC_MODNAME_SYS_BUILTIN variable MUST BE an existing system " + "builtin module; this test is aborted", + ) + +# Oppositional, PC_MODNAME_DEFAULT must NOT be a pre-existing system built-in module +if PC_MODNAME_DEFAULT in sys.modules: + # We are not authorized to tamper outside our test area + pytest.exit( + errno.EACCES, + f" Cannot reuses a system built-in module {PC_MODNAME_DEFAULT};" + " this test is aborted", + ) + + +class TestSettingsModuleName(unittest.TestCase): + """load_source() w/ module_name arg""" + + # Exercises both the path and module_name arguments""" + def setUp(self): + self.old_locale = locale.setlocale(locale.LC_ALL) + locale.setlocale(locale.LC_ALL, "C") + self.saved_sys_modules = sys.modules + + # Something interesting ...: + # below logic only works with ALL classes within a file + # and does not work within a selective class. + # So logic within this setUp() is file-wide, not per-class. + + args = inspect.getfullargspec(load_source) + if ("name" not in args.args) and ( + args.args.__len__ != load_source_argument_count + ): + # Skip this entire test file if load_source() only supports 1 argument + pytest.skip( + "this class is only used with load_source() having " + "support for a 'module_name' argument" + ) + + def tearDown(self): + locale.setlocale(locale.LC_ALL, self.old_locale) + if PC_MODNAME_SYS_BUILTIN not in sys.modules: + AssertionError( + f"A built-in module named {PC_MODNAME_SYS_BUILTIN} got " + "deleted; test setup failed" + ) + if PC_MODNAME_DEFAULT in sys.modules: + del sys.modules[PC_MODNAME_DEFAULT] + AssertionError( + f"One of many unittests did not remove {PC_MODNAME_DEFAULT} module." + ) + if PC_MODNAME_VALID in sys.modules: + del sys.modules[PC_MODNAME_VALID] + AssertionError( + f"One of many unittests did not remove {PC_MODNAME_VALID} module." + ) + if PC_MODNAME_UNREADABLE in sys.modules: + del sys.modules[PC_MODNAME_UNREADABLE] + AssertionError( + f"One of many unittests did not remove {PC_MODNAME_UNREADABLE} module." + ) + if PC_MODNAME_NOT_EXIST in sys.modules: + del sys.modules[PC_MODNAME_NOT_EXIST] + AssertionError( + f"One of many unittests did not remove {PC_MODNAME_NOT_EXIST} module." + ) + if PC_MODNAME_DOTTED in sys.modules: + del sys.modules[PC_MODNAME_DOTTED] + AssertionError( + f"One of many unittests did not remove {PC_MODNAME_DOTTED} module." + ) + if self.saved_sys_modules != sys.modules: + AssertionError( + "sys.modules was not restored to its original glory; " + "investigate faulty unit test" + ) + # TODO delete any straggling temporary directory? + + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + """add support for an assert based on subpattern in `caplog.text` output""" + self._caplog = caplog + + # Blank arguments test series, by path argument, str type + def test_load_source_str_all_blank_fail(self): + """arguments all blank, str type; failing mode""" + # Supply blank string to each argument and fail + blank_filespec_str: str = "" + module_name_str = "" + + module_spec = load_source(module_name_str, blank_filespec_str) # NOQA: RUF100 + assert module_spec is None + + # Proper combinatorial - Focusing firstly on the path argument using str type + def test_load_source_str_rel_dotted_fail(self): + """dotted relative directory, str type; blank module; failing mode""" + dotted_filespec_str: str = "." + module_name_str = "" + + module_spec = load_source(module_name_str, dotted_filespec_str) # NOQA: RUF100 + assert module_spec is None + + def test_load_source_str_rel_parent_fail(self): + """relative parent, str type; blank module; failing mode""" + parent_filespec_str: str = ".." + # Let the load_source() determine its module name + module_name_str = "" + + module_spec = load_source(module_name_str, parent_filespec_str) # NOQA: RUF100 + assert module_spec is None + + def test_load_source_str_anchor_fail(self): + """anchor directory, str type; blank module; failing mode""" + anchor_filespec_str: str = os.sep + # Let the load_source() determine its module name + module_name_str = "" + + module_spec = load_source(module_name_str, anchor_filespec_str) # NOQA: RUF100 + assert module_spec is None + + def test_load_source_str_cwd_fail(self): + """current working dir, str type; blank module; failing mode""" + cwd_filespec_str: str = str(Path.cwd()) + # Let the load_source() determine its module name + module_name_str = "" + + module_spec = load_source(module_name_str, cwd_filespec_str) + assert module_spec is None + + # Focusing on the path argument using Path type + def test_load_source_path_all_blank_fail(self): + """arguments blank; Path type; blank module; failing mode""" + # Supply blank string to each argument and fail + blank_filespec_path: Path = Path("") + # Let the load_source() determine its module name + module_name_str = "" + + module_spec = load_source(module_name_str, blank_filespec_path) + assert module_spec is None + + def test_load_source_path_dot_fail(self): + """dotted directory, Path type; blank module; failing mode""" + dotted_filespec_path: Path = Path(".") + # Let the load_source() determine its module name + module_name_str = "" + + module_spec = load_source(module_name_str, dotted_filespec_path) + assert module_spec is None + + def test_load_source_path_abs_anchor_fail(self): + """anchor (absolute) directory, Path type; blank module; failing mode""" + anchor_filespec_path: Path = Path(os.sep) + # Let the load_source() determine its module name + module_name_str = "" + + module_spec = load_source(module_name_str, anchor_filespec_path) + assert module_spec is None + + def test_load_source_path_rel_parent_fail(self): + """parent relative directory, Path type; blank module; failing mode""" + parent_filespec_path: Path = Path("..") + # Let the load_source() determine its module name + module_name_str = "" + + module_spec = load_source(module_name_str, parent_filespec_path) + assert module_spec is None + + def test_load_source_path_abs_cwd_fail(self): + """current working (absolute) dir, Path type, blank module; failing mode""" + blank_filespec_path: Path = Path.cwd() + # Let the load_source() determine its module name + module_name_str = "" + + module_spec = load_source(module_name_str, blank_filespec_path) + assert module_spec is None + + # Actually start to try using Pelican configuration file + # but with no module_name + def test_load_source_str_rel_valid_pass(self): + """valid relative path, str type; blank module; passing mode""" + tmp_rel_path: Path = Path( + tempfile.mkdtemp(dir=DIRSPEC_RELATIVE, suffix=TMP_FILENAME_SUFFIX) + ) + valid_rel_filespec_str: str = str(tmp_rel_path / Path(".") / PC_FULLNAME_VALID) + # Copy file to absolute temporary directory + shutil.copy(RO_FILESPEC_REL_VALID_PATH, valid_rel_filespec_str) + + # Let the load_source() determine its module name + module_name_str = "" + if PC_MODNAME_VALID in sys.modules: + AssertionError( + f"{PC_MODNAME_VALID} is still in sys.modules; fatal error " f"out" + ) + + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + # ignore return value due to sys.exit() + module_spec = load_source(module_name_str, valid_rel_filespec_str) + assert module_spec is not None + assert hasattr(module_spec, "PATH"), ( + f"The {valid_rel_filespec_str} file did not provide a PATH " + "object variable having a valid directory name, absolute or relative." + ) + + # Cleanup + if PC_MODNAME_VALID in sys.modules: # module_name is blank + # del module after ALL asserts, errnos, and STDOUT + del sys.modules[PC_MODNAME_VALID] + Path(valid_rel_filespec_str).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here + shutil.rmtree(tmp_rel_path) + + def test_load_source_str_abs_valid_pass(self): + """valid absolute path, str type; blank module; passing mode""" + # Set up absolute temporary directory + tmp_abs_path: Path = Path(tempfile.mkdtemp(TMP_FILENAME_SUFFIX)) + valid_abs_filespec_str: str = str(tmp_abs_path / PC_FULLNAME_VALID) + # Let the load_source() determine its module name + module_name_str = "" + if PC_MODNAME_VALID in sys.modules: + AssertionError( + f"{PC_MODNAME_VALID} is still in sys.modules; fatal error " f"out" + ) + + # Copy file to absolute temporary directory + shutil.copy(RO_FILESPEC_REL_VALID_PATH, valid_abs_filespec_str) + + module_spec = load_source(module_name_str, valid_abs_filespec_str) + # check if PATH is defined inside a valid Pelican configuration settings file + assert module_spec is not None + assert hasattr(module_spec, "PATH"), ( + f"The {valid_abs_filespec_str} file did not provide a PATH " + "object variable having a valid directory name, absolute or relative." + ) + + # Cleanup + if PC_MODNAME_VALID in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_VALID] + Path(valid_abs_filespec_str).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here + shutil.rmtree(tmp_abs_path) + + def test_load_source_str_rel_not_found_fail(self): + """relative not found, str type; blank module; failing mode""" + if RO_FILESPEC_REL_NOTFOUND_PATH.exists(): + AssertionError(f"{RO_FILESPEC_REL_NOTFOUND_PATH} should not exist.") + missing_rel_filespec_str: str = str(RO_FILESPEC_REL_NOTFOUND_PATH) + # Let the load_source() determine its module name + module_name_str = "" + + # since load_source only returns None or Module, check STDERR for 'not found' + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(module_name_str, missing_rel_filespec_str) + # but we have to check for warning + # message of 'assumed implicit module name' + assert module_spec is None + assert " not found" in self._caplog.text + + def test_load_source_str_abs_not_found_fail(self): + """absolute not found, str type; blank module; failing mode""" + # Set up absolute temporary directory + tmp_abs_dirspec: Path = Path(tempfile.mkdtemp(TMP_FILENAME_SUFFIX)) + notfound_abs_filespec_path: Path = tmp_abs_dirspec / PC_FULLNAME_NOTFOUND + # No need to copy file, but must check that none is there + if notfound_abs_filespec_path.exists(): + # Ouch, to delete or to absolute fail? We fail here, instead. + AssertionError(f"Errant '{notfound_abs_filespec_path} found; FAILED") + # Let the load_source() determine its module name + module_name_str = "" + + # since load_source only returns None or Module, check STDERR for 'not found' + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(module_name_str, notfound_abs_filespec_path) + # but we have to check for warning + # message of 'assumed implicit module name' + assert module_spec is None + assert " not found" in self._caplog.text + + # Cleanup + # This tree should be empty + shutil.rmtree(tmp_abs_dirspec) + + def test_load_source_str_rel_no_access_fail(self): + """relative not readable, str type; blank module; failing mode""" + # Set up relative temporary directory "settings/pelicanXXXXXX" + rel_tmp_path: Path = Path( + tempfile.mkdtemp( + dir=DIRSPEC_RELATIVE, + suffix=TMP_FILENAME_SUFFIX, + ) + ) + noaccess_rel_filespec_path: Path = rel_tmp_path / PC_FULLNAME_UNREADABLE + # despite tempdir, check if file does NOT exist + if noaccess_rel_filespec_path.exists(): + # Bad test setup, assert out + AssertionError( + f"File {noaccess_rel_filespec_path} should not " "exist in tempdir" + ) + noaccess_rel_filespec_path.touch() # wonder if GitHub preserves no-read bit + remove_read_permissions(str(noaccess_rel_filespec_path)) + noaccess_rel_filespec_str = str(noaccess_rel_filespec_path) + # File must exist; but one must check that it is unreadable there + if os.access(noaccess_rel_filespec_str, os.R_OK): + # Ouch, to change file perm bits or to absolute fail? Fail here, instead. + AssertionError( + f"Errant '{noaccess_rel_filespec_str} unexpectedly readable; FAILED" + ) + + # Let the load_source() determine its module name + module_name_str = "" + if PC_MODNAME_UNREADABLE in sys.modules: + AssertionError( + f"{PC_MODNAME_UNREADABLE} is still in sys.modules; fatal " f"error out" + ) + + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(module_name_str, noaccess_rel_filespec_str) + # but we have to check for a warning + # message of 'assumed implicit module name' + assert module_spec is None + assert " is not readable" in self._caplog.text + + # Cleanup + if PC_MODNAME_UNREADABLE in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_UNREADABLE] + Path(noaccess_rel_filespec_path).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(rel_tmp_path) + + def test_load_source_str_abs_no_access_fail(self): + """absolute not readable, str type; blank module; failing mode""" + # Set up absolute temporary "/$TEMPDIR/pelicanXXXXXX" + abs_tmp_path: Path = Path( + tempfile.mkdtemp( + # dir= supplies us with absolute default, as a default + suffix=TMP_FILENAME_SUFFIX, + ) + ) + noaccess_abs_filespec_path: Path = abs_tmp_path / PC_FULLNAME_UNREADABLE + # despite tempdir, check if file does NOT exist + if noaccess_abs_filespec_path.exists(): + # Bad test setup, assert out + AssertionError( + f"File {noaccess_abs_filespec_path} should not " "exist in tempdir" + ) + noaccess_abs_filespec_path.touch() + remove_read_permissions(str(noaccess_abs_filespec_path)) + # do not need to copy REL into ABS but need to ensure no ABS is there + if os.access(noaccess_abs_filespec_path, os.R_OK): + # Ouch, to change file perm bits or to absolute fail? Fail here, instead. + AssertionError( + f"Errant '{noaccess_abs_filespec_path} unexpectedly " "readable; FAILED" + ) + + # Let the load_source() determine its module name + module_name_str = "" + if PC_FILENAME_UNREADABLE in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_FILENAME_UNREADABLE] + + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(module_name_str, noaccess_abs_filespec_path) + # but we have to check for warning + # message of 'assumed implicit module name' + assert module_spec is None + assert " is not readable" in self._caplog.text + + # Cleanup + if PC_FILENAME_UNREADABLE in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_FILENAME_UNREADABLE] + Path(noaccess_abs_filespec_path).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(abs_tmp_path) + + # continue using the path argument, but starting with Path type, + + def test_load_source_path_rel_valid_pass(self): + """valid relative path, Path type; blank module; passing mode""" + # Use pelicanconf straight out of settings/pelicanconf-valid.py; no tempdir + # Set up temporary relative "settings/pelicanXXXXXX" + tmp_rel_path: Path = Path( + tempfile.mkdtemp(dir=DIRSPEC_RELATIVE, suffix=TMP_FILENAME_SUFFIX) + ) + valid_rel_filespec_path: Path = tmp_rel_path / PC_FULLNAME_VALID + shutil.copyfile(RO_FILESPEC_REL_VALID_PATH, valid_rel_filespec_path) + + module_name_str = "" + if PC_FILENAME_VALID in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_FILENAME_VALID] + + module_spec = load_source(module_name_str, valid_rel_filespec_path) + # check if PATH is defined inside a valid Pelican configuration settings file + assert module_spec is not None + assert hasattr(module_spec, "PATH"), ( + f"The {valid_rel_filespec_path} file did not provide a PATH " + "object variable having a valid directory name, absolute or relative." + ) + + # Cleanup + if PC_MODNAME_VALID in sys.modules: # module_name is blank + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_VALID] + Path(valid_rel_filespec_path).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(tmp_rel_path) + + def test_load_source_path_abs_valid_pass(self): + """valid absolute path, Path type; blank module; passing mode""" + # Set up temporary absolute "/$TEMPDIR/pelicanXXXXXX" + abs_tmp_path: Path = Path( + tempfile.mkdtemp( + # dir= supplies us with absolute default, as a default + suffix=TMP_FILENAME_SUFFIX, + ) + ) + valid_abs_filespec_path: Path = abs_tmp_path / PC_FULLNAME_VALID + shutil.copy(RO_FILESPEC_REL_VALID_PATH, valid_abs_filespec_path) + + # Let the load_source() determine its module name + module_name_str = "" + if PC_MODNAME_VALID in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_VALID] + + module_spec = load_source(module_name_str, valid_abs_filespec_path) + # check if PATH is defined inside a valid Pelican configuration settings file + assert module_spec is not None + assert hasattr(module_spec, "PATH"), ( + f"The {valid_abs_filespec_path} file did not provide a PATH " + "object variable having a valid directory name, absolute or relative." + ) + + # Cleanup + if PC_MODNAME_VALID in sys.modules: # module_name is blank + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_VALID] + Path(valid_abs_filespec_path).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(abs_tmp_path) + + def test_load_source_path_rel_not_found_fail(self): + """relative not found, Path type; blank module; failing mode""" + # Set up temporary relative "settings/pelicanXXXXXX" + tmp_rel_dirspec_path: Path = Path( + tempfile.mkdtemp(dir=DIRSPEC_RELATIVE, suffix=TMP_FILENAME_SUFFIX) + ) + notfound_rel_filespec_path: Path = tmp_rel_dirspec_path / PC_FULLNAME_NOTFOUND + # No need to copy file, but must check that none is there + if notfound_rel_filespec_path.exists(): + # Ouch, to delete or to absolute fail? We fail here, instead. + AssertionError(f"did not expect {notfound_rel_filespec_path} in a tempdir.") + + # Let the load_source() determine its module name + module_name_str = "" + if PC_MODNAME_NOT_EXIST in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_NOT_EXIST] + + # since load_source only returns None or Module, check STDERR for 'not found' + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(module_name_str, notfound_rel_filespec_path) + # but we have to check for warning + # message of 'assumed implicit module name' + assert " not found" in self._caplog.text + assert module_spec is None + + # Cleanup temporary + if PC_MODNAME_NOT_EXIST in sys.modules: # module_name is blank + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_NOT_EXIST] + shutil.rmtree(tmp_rel_dirspec_path) + + def test_load_source_path_abs_not_found_fail(self): + """absolute not found, Path type; blank module; failing mode""" + # Set up temporary + tmp_abs_dirspec_path: Path = Path(tempfile.mkdtemp(TMP_FILENAME_SUFFIX)) + missing_abs_filespec_path: Path = tmp_abs_dirspec_path / PC_FULLNAME_NOTFOUND + # No need to copy file, but must check that none is there + if missing_abs_filespec_path.exists(): + # Ouch, to delete or to absolute fail? We fail here, instead. + AssertionError(f"Errant '{missing_abs_filespec_path} found; FAILED") + + # Let the load_source determine its module name, error-prone + module_name_str = "" + if PC_MODNAME_NOT_EXIST in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_NOT_EXIST] + + # since load_source only returns None or Module, check STDERR for 'not found' + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(module_name_str, missing_abs_filespec_path) + # but we have to check for warning + # message of 'assumed implicit module name' + assert module_spec is None + assert " not found" in self._caplog.text + + if PC_MODNAME_NOT_EXIST in sys.modules: # module_name is blank + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_NOT_EXIST] + shutil.rmtree(tmp_abs_dirspec_path) + + def test_load_source_path_rel_no_access_fail(self): + """relative not readable, Path type; blank module; failing mode""" + # Set up temporary + tmp_rel_dirspec_path: Path = Path( + tempfile.mkdtemp(dir=DIRSPEC_RELATIVE, suffix=TMP_FILENAME_SUFFIX) + ) + noaccess_rel_filespec_path: Path = tmp_rel_dirspec_path / PC_FULLNAME_UNREADABLE + # No need to copy file, but must check that none is there + if noaccess_rel_filespec_path.exists(): + # Ouch, to delete or to absolute fail? We fail here, instead. + AssertionError(f"Errant '{noaccess_rel_filespec_path} found; FAILED") + # wonder if GitHub preserves no-read bit (Update: Nope, gotta roll our own) + Path(noaccess_rel_filespec_path).touch() + remove_read_permissions(str(noaccess_rel_filespec_path)) + # do not need to copy REL into ABS but need to ensure no ABS is there + if os.access(noaccess_rel_filespec_path, os.R_OK): + # Ouch, to change file perm bits or to absolute fail? Fail here, instead. + AssertionError( + f"Errant '{noaccess_rel_filespec_path} unexpectedly " "readable; FAILED" + ) + + # Let the load_source() determine its module name + module_name_str = "" + if PC_MODNAME_UNREADABLE in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_UNREADABLE] + + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(module_name_str, noaccess_rel_filespec_path) + # but we have to check for a warning + # message of 'assumed implicit module name' + assert module_spec is None + assert " is not readable" in self._caplog.text + + # Cleanup + if PC_MODNAME_UNREADABLE in sys.modules: # module_name is blank + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_UNREADABLE] + Path(noaccess_rel_filespec_path).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(tmp_rel_dirspec_path) + + def test_load_source_path_abs_no_access_fail(self): + """absolute not readable, Path type; blank module; failing mode""" + # Set up temporary relative "/$TEMPDIR/pelicanXXXXXX" + tmp_abs_dirspec_path: Path = Path( + tempfile.mkdtemp( + # dir= supplies us with absolute default, as a default + suffix=TMP_FILENAME_SUFFIX, + ) + ) + noaccess_abs_filespec_path: Path = tmp_abs_dirspec_path / PC_FULLNAME_UNREADABLE + # despite tempdir, check if file does NOT exist + if noaccess_abs_filespec_path.exists(): + # Bad test setup, assert out + AssertionError( + f"File {noaccess_abs_filespec_path} should not " "exist in tempdir" + ) + noaccess_abs_filespec_path.touch() # wonder if GitHub preserves no-read bit + remove_read_permissions(str(noaccess_abs_filespec_path)) + # do not need to copy REL into ABS but need to ensure no ABS is there + if os.access(noaccess_abs_filespec_path, os.R_OK): + # Ouch, to change file perm bits or to absolute fail? + # Test setup fail here, Assert-hard. + AssertionError( + f"Errant '{noaccess_abs_filespec_path} unexpectedly " "readable; FAILED" + ) + + # Let the load_source() determine its module name + module_name_str = "" + if PC_MODNAME_NOT_EXIST in sys.modules: + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_NOT_EXIST] + + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(module_name_str, noaccess_abs_filespec_path) + assert module_spec is None + assert " is not readable" in self._caplog.text + + # Cleanup + if PC_MODNAME_NOT_EXIST in sys.modules: # module_name is blank + # del module after ALL asserts, errnos, and STDOUT; before file removal + del sys.modules[PC_MODNAME_NOT_EXIST] + Path(noaccess_abs_filespec_path).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(tmp_abs_dirspec_path) + + # Everything afterward is all about the module_name + + # Start using module_name, but with valid (str type) path always + # (will test valid module_name_str with invalid path afterward) + + def test_load_source_module_str_valid_pass(self): + """valid module, relative path str type; passing mode""" + # In Pelican, module name shall always be 'pelicanconf' + module_name_str = PC_MODNAME_DEFAULT + if module_name_str in sys.modules: + AssertionError( + f"Module {module_name_str} is still pre-loaded; fatal " + f"setup error; aborted" + ) + + tmp_abs_dirspec_path: Path = Path( + tempfile.mkdtemp( + # dir= supplies us with absolute default, as a default + suffix=TMP_FILENAME_SUFFIX, + ) + ) + valid_abs_filespec_path: Path = tmp_abs_dirspec_path / PC_FULLNAME_VALID + shutil.copyfile(RO_FILESPEC_REL_VALID_PATH, valid_abs_filespec_path) + + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(module_name_str, valid_abs_filespec_path) + # but we have to check for warning + # message of 'assumed implicit module name' + assert module_spec is not None + assert hasattr(module_spec, "PATH"), ( + f"The {valid_abs_filespec_path} file did not provide a PATH " + "object variable having a valid directory name, absolute or relative." + ) + assert "Loaded module" in self._caplog.text + + # Cleanup + if module_name_str in sys.modules: # module_name used, not extracted from file + # del module after ALL asserts, errnos, and STDOUT; but before file removal + del sys.modules[module_name_str] + Path(valid_abs_filespec_path).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(tmp_abs_dirspec_path) + + def test_load_source_module_str_rel_syntax_error_fail(self): + """syntax error, relative path, str type; failing mode""" + # In Pelican, module name shall always be 'pelicanconf' + module_name_str = PC_MODNAME_DEFAULT + if module_name_str in sys.modules: + AssertionError( + f"Module {module_name_str} is previously loaded; fatal " + f"test setup; aborted" + ) + + # copy "pseudo-script" file into 'settings/pelicanXXXXX/(here)' + # An essential avoidance of ruff/black's own syntax-error asserts + blob: str = str(BLOB_FILESPEC_SYNTAX_ERROR) + # Set up temporary relative "settings/pelicanXXXXXX/(here)" + tmp_rel_dirspec_path: Path = Path( + tempfile.mkdtemp(dir=DIRSPEC_RELATIVE, suffix=TMP_FILENAME_SUFFIX) + ) + syntax_err_rel_filespec_str: str = str( + tmp_rel_dirspec_path / PC_FULLNAME_SYNTAX_ERROR + ) + # Copy mangled pseudo-Python file into temporary area as a Python file + shutil.copyfile(blob, syntax_err_rel_filespec_str) + + with self._caplog.at_level(logging.DEBUG): + with pytest.raises(SystemExit) as sample: + self._caplog.clear() + + # ignore return value due to sys.exit() + load_source(module_name_str, path=syntax_err_rel_filespec_str) + assert sample.type == SystemExit + assert sample.value.code == errno.ENOEXEC + assert "invalid syntax" in self._caplog.text + + # Cleanup temporary + if module_name_str in sys.modules: # module_name used, not extracted from file + # del module after ALL asserts, errnos, and STDOUT + del sys.modules[module_name_str] + Path(syntax_err_rel_filespec_str).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(tmp_rel_dirspec_path) + + def test_load_source_module_str_abs_syntax_error_fail(self): + """ "syntax error; absolute path, str type; passing mode""" + # In Pelican, module name shall always be 'pelicanconf' + module_name_str = PC_MODNAME_DEFAULT + if module_name_str in sys.modules: + AssertionError( + f"Module {module_name_str} is still preloaded; fatal " f"error; aborted" + ) + + # identify blob of "pseudo-script" file (ruff/black avoidance of syntax-error) + blob: str = str(Path(DIRSPEC_RELATIVE) / BLOB_FULLNAME_SYNTAX_ERROR) + # Set up temporary absolute "/$TEMPDIR/pelicanXXXXXX/(here)" + tmp_abs_dirspec_path: Path = Path( + tempfile.mkdtemp( + # dir= supplies us with absolute default, as a default + suffix=TMP_FILENAME_SUFFIX + ) + ) + syntax_err_abs_filespec_str: str = str( + tmp_abs_dirspec_path / PC_FULLNAME_SYNTAX_ERROR + ) + # despite tempdir, check if file does NOT exist + if Path(syntax_err_abs_filespec_str).exists(): + # Bad test setup, assert out + AssertionError( + f"File {syntax_err_abs_filespec_str} should not " "exist in tempdir" + ) + # Copy mangled pseudo-Python file into temporary absolute area as a Python file + shutil.copyfile(blob, syntax_err_abs_filespec_str) + + with self._caplog.at_level(logging.DEBUG): + with pytest.raises(SystemExit) as sample: + self._caplog.clear() + + # ignore return value due to sys.exit() + load_source(module_name_str, syntax_err_abs_filespec_str) + assert sample.type == SystemExit + assert sample.value.code == errno.ENOEXEC + assert "invalid syntax" in self._caplog.text + + # Cleanup + if module_name_str in sys.modules: # module_name used, not extracted from file + # del module after ALL asserts, errnos, and STDOUT + del sys.modules[module_name_str] + Path(syntax_err_abs_filespec_str).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(tmp_abs_dirspec_path) + + # Start using module_name, but with valid (path type) path always + def test_load_source_perfect_pass(self): + """valid module name; valid relative file, Path type; passing mode""" + # In Pelican, module name shall always be 'pelicanconf' + module_name_str = PC_MODNAME_DEFAULT + if module_name_str in sys.modules: + AssertionError( + f"Module {PC_MODNAME_DEFAULT} is still pre-loaded; fatal " + f"setup error; aborted." + ) + + # Set up temporary absolute "/$TEMPDIR/pelicanXXXXXX" + tmp_rel_path: Path = Path( + tempfile.mkdtemp( + dir=DIRSPEC_RELATIVE, + suffix=TMP_FILENAME_SUFFIX, + ) + ) + valid_rel_filespec_path: Path = tmp_rel_path / PC_FULLNAME_VALID + shutil.copy(RO_FILESPEC_REL_VALID_PATH, valid_rel_filespec_path) + + # Setup to capture STDOUT + with self._caplog.at_level(logging.DEBUG): + # Clear any STDOUT for our upcoming regex pattern test + self._caplog.clear() + + module_spec = load_source(module_name_str, valid_rel_filespec_path) + # but we have to check for warning + # message of 'assumed implicit module name' + assert module_spec is not None + assert hasattr(module_spec, "PATH"), ( + f"The {valid_rel_filespec_path} file did not provide a PATH " + "object variable having a valid directory name, absolute or relative." + ) + assert "Loaded module" in self._caplog.text + + # Cleanup + if module_name_str in sys.modules: # module_name used, not extracted from file + # del module after ALL asserts, errnos, and STDOUT + del sys.modules[module_name_str] + Path(valid_rel_filespec_path).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(tmp_rel_path) + + def test_load_source_module_path_rel_syntax_error_fail(self): + """Syntax error; valid relative file, Path type; valid module; passing mode""" + # In Pelican, module name shall always be 'pelicanconf' + module_name_str = PC_MODNAME_DEFAULT + if module_name_str in sys.modules: + AssertionError( + f"Module {module_name_str} is still preloaded; fatal setup " + f"error; aborted." + ) + + # identify blob of "pseudo-script" file (ruff/black avoidance of syntax-error) + blob: str = str(Path(DIRSPEC_RELATIVE) / BLOB_FULLNAME_SYNTAX_ERROR) + # Set up temporary relative "settings/pelicanXXXXXX/(here)" + tmp_rel_dirspec_path: Path = Path( + tempfile.mkdtemp(dir=DIRSPEC_RELATIVE, suffix=TMP_FILENAME_SUFFIX) + ) + syntax_err_rel_filespec_path: Path = ( + tmp_rel_dirspec_path / PC_FULLNAME_SYNTAX_ERROR + ) + # despite tempdir, check if file does NOT exist + if syntax_err_rel_filespec_path.exists(): + # Bad test setup, assert out + AssertionError( + f"File {syntax_err_rel_filespec_path!s} should not " "exist in tempdir" + ) + # Copy mangled pseudo-Python file into temporary absolute area as a Python file + shutil.copyfile(blob, syntax_err_rel_filespec_path) + + with self._caplog.at_level(logging.DEBUG): + with pytest.raises(SystemExit) as sample: + self._caplog.clear() + + # ignore return value due to sys.exit() + load_source(module_name_str, path=syntax_err_rel_filespec_path) + assert sample.type == SystemExit + assert sample.value.code == errno.ENOEXEC + assert "invalid syntax" in self._caplog.text + + # Cleanup + if module_name_str in sys.modules: # module_name used, not extracted from file + # del module after ALL asserts, errnos, and STDOUT + del sys.modules[module_name_str] + Path(syntax_err_rel_filespec_path).unlink(missing_ok=True) + + def test_load_source_module_path_abs_syntax_error_fail(self): + """Syntax error; valid absolute file, Path type; valid module; passing mode""" + # In Pelican, module name shall always be 'pelicanconf' + module_name_str = PC_MODNAME_DEFAULT + if module_name_str in sys.modules: + AssertionError( + f"Module {module_name_str} is still preloaded; fatal setup " + f"error; aborted." + ) + + # Set up temporary absolute "/$TEMPDIR/pelicanXXXXXX/(here)" + tmp_abs_dirspec_path: Path = Path( + tempfile.mkdtemp( + # dir= supplies us with absolute default, as a default + suffix=TMP_FILENAME_SUFFIX + ) + ) + syntax_err_abs_filespec_path: Path = ( + tmp_abs_dirspec_path / PC_FULLNAME_SYNTAX_ERROR + ) + # copy "pseudo-script" file to '/tmp' (ruff/black avoidance of syntax-error) + blob = Path(DIRSPEC_DATADIR) / BLOB_FULLNAME_SYNTAX_ERROR + # despite tempdir, check if file does NOT exist + if Path(syntax_err_abs_filespec_path).exists(): + # Bad test setup, assert out + AssertionError( + f"File {syntax_err_abs_filespec_path} should not " "exist in tempdir" + ) + # Copy mangled pseudo-Python file into temporary area as a Python file + shutil.copyfile(blob, syntax_err_abs_filespec_path) + + with self._caplog.at_level(logging.DEBUG): + with pytest.raises(SystemExit) as sample: + self._caplog.clear() + + # ignore return value due to sys.exit() + module_type = load_source( + module_name_str, + syntax_err_abs_filespec_path, + ) + assert module_type is not None + assert sample.type == SystemExit + assert sample.value.code == errno.ENOEXEC + assert "invalid syntax" in self._caplog.text + + # Cleanup temporary + if module_name_str in sys.modules: # module_name used, not derived from file + # del module after ALL asserts, errnos, and STDOUT + del sys.modules[module_name_str] + Path(syntax_err_abs_filespec_path).unlink(missing_ok=False) + # There is a danger of __pycache__ being overlooked here only if this fails + shutil.rmtree(tmp_abs_dirspec_path) + + # Start misusing the module_name, but with valid (path type) path always + def test_load_source_module_invalid_fail(self): + """Non-existent module name; valid relative file, Path type; passing mode""" + module_not_exist = PC_MODNAME_NOT_EXIST + valid_filespec = RO_FILESPEC_REL_VALID_PATH + + if module_not_exist in sys.modules: + AssertionError( + f"Test setup error; module {module_not_exist} not " + "supposed to exist. Aborted" + ) + module_spec = load_source(module_not_exist, valid_filespec) + # TODO Probably needs another assert here + assert module_spec is not None + + # Cleanup + # Really cannot clean up a module if it is not supposed to exist + # It could be an accidental but actual module that we mistakenly targeted, + # so do nothing for cleanup + + def test_load_source_module_taken_by_builtin_fail(self): + """Built-in module name; valid relative file, Path type; failing mode""" + module_name_taken_by_builtin = PC_MODNAME_SYS_BUILTIN + # Check if it IS a system built-in module + if module_name_taken_by_builtin not in sys.modules: + AssertionError( + f"Module {module_name_taken_by_builtin} is not a built-in " + f"module; redefine PC_MONDNAME_SYS_BUILTIN" + ) + valid_rel_filespec_path = RO_FILESPEC_REL_VALID_PATH + # Taking Python system builtin module is always a hard exit + with self._caplog.at_level(logging.DEBUG): + with pytest.raises(SystemExit) as sample: + self._caplog.clear() + + module_spec = load_source( + module_name_taken_by_builtin, valid_rel_filespec_path + ) + # assert "taken by builtin" in self._caplog.text # TODO add caplog here + assert sample.type == SystemExit + assert sample.value.code == errno.ENOEXEC + assert module_spec is None + assert "reserved the module name" in self._caplog.text + # DO NOT PERFORM del sys.module[] here, this is a built-in + # But do check that built-in module is left alone + if module_name_taken_by_builtin not in sys.modules: + pytest.exit( + f"Module {module_name_taken_by_builtin} is not a built-in " + f"module; redefine PC_MODNAME_SYS_BUILTIN" + ) + + def test_load_source_module_dotted_fail(self): + """Dotted module name; valid relative file, Path type; failing mode""" + dotted_module_name_str = PC_MODNAME_DOTTED + valid_rel_filespec_str = str(RO_FILESPEC_REL_VALID_PATH) + # Taking Python system builtin module is always a hard exit + with self._caplog.at_level(logging.DEBUG): + self._caplog.clear() + + module_spec = load_source(dotted_module_name_str, valid_rel_filespec_str) + assert module_spec is None + assert "Cannot use dotted module name" in self._caplog.text + # DO NOT PERFORM del sys.module[] here, this is an impossible dotted module + + def test_load_source_only_valid_module_str_fail(self): + """only valid module_name, str type, failing mode""" + # don't let Pelican search all over the places using PYTHONPATH + module_spec = load_source(name=str(PC_MODNAME_DEFAULT), path="") + # not sure if STDERR capture is needed + assert module_spec is None + # TODO load_source() always always assert this SystemExit; add assert here? + + +class TestSettingsGetFromFile(unittest.TestCase): + """Exercises get_from_settings_file()""" + + def test_get_from_file(self): + assert True + + +if __name__ == "__main__": + unittest.main() diff --git a/pelican/tests/test_settings_path.py b/pelican/tests/test_settings_path.py new file mode 100644 index 000000000..3d6203750 --- /dev/null +++ b/pelican/tests/test_settings_path.py @@ -0,0 +1,307 @@ +# +# Focused on settings.py/load_source(), specifically pathlib.Path type +# +# Ruff wants '# NOQA: RUF100' +# PyCharm wants '# : RUF100' +# RUFF says PyCharm is a no-go; stay with RUFF, ignore PyCharm's NOQA orange warnings + +import copy +import locale +import logging +import os +import shutil +import tempfile +from pathlib import Path + +import pytest +from _pytest.logging import LogCaptureHandler, _remove_ansi_escape_sequences # NOQA + +from pelican.settings import ( + load_source, +) + +TMP_DIRNAME_SUFFIX = "pelican" + +DIRSPEC_RELATIVE = "settings" + os.sep + +EXT_PYTHON = ".py" +EXT_PYTHON_DISABLED = ".disabled" + +PC_MODNAME_ACTUAL = "pelicanconf" + +# FILENAME_: file name without the extension +PC_FILENAME_DEFAULT = PC_MODNAME_ACTUAL +PC_FILENAME_VALID = "pelicanconf-valid" +PC_FILENAME_SYNTAX_ERROR = "pelicanconf-syntax-error" +PC_FILENAME_SYNTAX2_ERROR = "pelicanconf-syntax-error2" + +# FULLNAME_: filename + extension +PC_FILENAME_DEFAULT: str = PC_FILENAME_DEFAULT + EXT_PYTHON +PC_FULLNAME_VALID: str = PC_FILENAME_VALID + EXT_PYTHON +PC_FULLNAME_SYNTAX_ERROR: str = PC_FILENAME_SYNTAX_ERROR + EXT_PYTHON +PC_FULLNAME_SYNTAX2_ERROR: str = PC_FILENAME_SYNTAX2_ERROR + EXT_PYTHON + +# Unit Test Case - Syntax Error attributes +UT_SYNTAX_ERROR_LINENO = 5 +UT_SYNTAX_ERROR_OFFSET = 1 +UT_SYNTAX_ERROR2_LINENO = 13 +UT_SYNTAX_ERROR2_OFFSET = 5 + +logging.basicConfig(level=0) +log = logging.getLogger(__name__) +logging.root.setLevel(logging.DEBUG) +log.propagate = True + + +# Note: Unittest test setUp/tearDown got replaced by Pytest and its fixtures. +# +# Pytest provides four levels of fixture scopes: +# +# * Function (Set up and tear down once for each test function) +# * Class (Set up and tear down once for each test class) +# * Module (Set up and tear down once for each test module/file) +# * Session (Set up and tear down once for each test session i.e. comprising +# one or more test files) +# +# The order of `def` fixtures/functions declarations within a source file +# does not matter, all `def`s can be in forward-reference order or +# backward-referencable. +# +# Weird thing about putting fixture(s) inside a function/procedure argument list +# is that the ordering of its argument DOES NOT matter: this is a block programming +# thing, not like most procedural programming languages. +# +# To see collection/ordering of fixtures, execute: +# +# pytest -n0 --setup-plan \ +# test_settings_config.py::TestSettingsConfig::test_cs_abs_tmpfile +# +# +# Using class in pytest is a way of aggregating similar test cases together. +class TestSettingsLoadSourcePath: + """load_source""" + + # Provided a file, it should read it, replace the default values, + # append new values to the settings (if any), and apply basic settings + # optimizations. + + @pytest.fixture(scope="module") + def fixture_module_get_tests_dir_abs_path(self): + """Get the absolute directory path of `tests` subdirectory + + This pytest module-wide fixture will provide a full directory + path of this `test_settings_config.py`. + + Note: used to assist in locating the `settings` directory underneath it. + + This fixture gets evoked exactly once (file-wide) due to `scope=module`. + + :return: Returns the Path of the tests directory + :rtype: pathlib.Path""" + abs_tests_dirpath: Path = Path(__file__).parent # secret sauce + return abs_tests_dirpath + + @pytest.fixture(scope="class") + def fixture_cls_get_settings_dir_abs_path( + self, fixture_module_get_tests_dir_abs_path + ) -> Path: + """Get the absolute directory path of `tests/settings` subdirectory + + This pytest class-wide fixture will provide the full directory + path of the `settings` subdirectory containing all the pelicanconf.py files. + + This fixture gets evoked exactly once within its entire class due + to `scope=class`. + + :return: Returns the Path of the tests directory + :rtype: pathlib.Path""" + settings_dirpath: Path = fixture_module_get_tests_dir_abs_path / "settings" + return settings_dirpath + + @pytest.fixture(scope="function") + def fixture_func_create_tmp_dir_abs_path( + self, + fixture_cls_get_settings_dir_abs_path, + # redundant to specify other dependencies of sub-fixtures here such as: + # fixture_cls_get_settings_dir_abs_path + ): + """Template the temporary directory + + This pytest function-wide fixture will provide the template name of + the temporary directory. + + This fixture executes exactly once every time a test case function references + this via `scope=function`.""" + temporary_dir_path: Path = Path( + tempfile.mkdtemp( + dir=fixture_cls_get_settings_dir_abs_path, suffix=TMP_DIRNAME_SUFFIX + ) + ) + # An insurance policy in case a unit test modified the temporary_dir_path var. + original_tmp_dir_path = copy.deepcopy(temporary_dir_path) + + yield temporary_dir_path + + shutil.rmtree(original_tmp_dir_path) + + @pytest.fixture(scope="function") + def fixture_func_ut_wrap(self, fixture_func_create_tmp_dir_abs_path): + """Unit test wrapper""" + # old setUp() portion + self.old_locale = locale.setlocale(locale.LC_ALL) + locale.setlocale(locale.LC_ALL, "C") + # each unit test will do the reading of settings + self.DIRSPEC_ABSOLUTE_TMP = fixture_func_create_tmp_dir_abs_path + + yield + # old tearDown() portion + locale.setlocale(locale.LC_ALL, self.old_locale) + + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + + # Emptiness + def test_load_source_arg_missing_fail(self): + """missing arguments; failing mode""" + with pytest.raises(TypeError) as sample: + load_source() # noqa: RUF100 + assert sample.type == TypeError + # assert sample.value.code only exists for SystemExit + + def test_load_source_path_str_blank_fail(self): + """blank string argument; failing mode""" + module_type = load_source("", "") + assert module_type is None + + def test_load_source_path_arg_str_blank_fail(self): + """argument name with blank str; failing mode""" + module_type = load_source(name="", path="") + assert module_type is None + + def test_load_source_wrong_arg_fail(self): + """wrong argument name (variant 1); failing mode""" + with pytest.raises(TypeError) as sample: + load_source(no_such_arg="reject this") # NOQA: RUF100 + assert sample.type == TypeError + # assert sample.value.code only exists for SystemExit + + def test_load_source_arg_unexpected_fail(self): + """wrong argument name (variant 2), failing mode""" + with pytest.raises(TypeError) as sample: + load_source(pathway="reject this") # NOQA: RUF100 + assert sample.type == TypeError + # assert sample.value.code only exists for SystemExit + + # Module Names, Oh My! + def test_load_source_module_arg_unexpected_list_fail(self): + """invalid dict argument type; failing mode""" + module_list = {} + with pytest.raises(TypeError) as sample: + load_source(module_name=module_list) # NOQA: RUF100 + assert sample.type == TypeError + + def test_load_source_module_path_arg_missing_fail(self): + """invalid list argument type; failing mode""" + module_str = "" + with pytest.raises(TypeError) as sample: + load_source(module_name=module_str) # NOQA: RUF100 + assert sample.type == TypeError + # assert sample.value.code only exists for SystemExit + + # All About The Paths + def test_load_source_path_unexpected_type_list_fail(self): + """invalid dict argument type with argument name; failing mode""" + path_list = {} + with pytest.raises(TypeError) as sample: + load_source(path=path_list) # NOQA: RUF100 + assert sample.type == TypeError + + def test_load_source_path_unexpected_type_dict_fail(self): + """invalid list argument type w/ argument name=; failing mode""" + path_dict = [] + with pytest.raises(TypeError) as sample: + load_source(path=path_dict) # NOQA: RUF100 + assert sample.type == TypeError + + def test_load_source_path_unexpected_type_tuple_fail(self): + """invalid tuple argument type w/ argument name=; failing mode""" + path_tuple = () + with pytest.raises(TypeError) as sample: + load_source(path=path_tuple) # NOQA: RUF100 + assert sample.type == TypeError + + def test_load_source_path_valid_pelicanconf_py_pass(self): + """correct working function call; passing mode""" + path: str = DIRSPEC_RELATIVE + PC_FULLNAME_VALID + module_type = load_source(name="", path=path) # extract module name from file + assert module_type is not None + + # @log_function_details + def test_load_source_path_pelicanconf_abs_syntax_error_fail( + self, + fixture_func_create_tmp_dir_abs_path, + fixture_cls_get_settings_dir_abs_path, + ): + """syntax error; absolute path; str type; failing mode""" + datadir_path = fixture_cls_get_settings_dir_abs_path + tmp_path = fixture_func_create_tmp_dir_abs_path + ro_filename = PC_FULLNAME_SYNTAX_ERROR + EXT_PYTHON_DISABLED + src_ro_filespec: Path = datadir_path / ro_filename + file_under_unit_test_filespec: Path = tmp_path / PC_FULLNAME_SYNTAX_ERROR + + # Copy mangled pseudo-Python file into temporary area as a Python file + shutil.copyfile(src_ro_filespec, file_under_unit_test_filespec) + + with self._caplog.at_level(logging.DEBUG): + with pytest.raises(SyntaxError) as sample: + self._caplog.clear() + + load_source(path=str(file_under_unit_test_filespec), name="") # NOQA: RUF100 + # ignore return value due to SyntaxError exception + + assert "unexpected indent" in self._caplog.text + assert sample.type == SyntaxError + assert sample.value.args[1]["lineno"] == UT_SYNTAX_ERROR_LINENO + assert sample.value.args[1]["offset"] == UT_SYNTAX_ERROR_OFFSET + + Path(file_under_unit_test_filespec).unlink(missing_ok=True) + + def test_load_source_path_pelicanconf_abs_syntax_error2_fail( + self, + fixture_func_create_tmp_dir_abs_path, + fixture_cls_get_settings_dir_abs_path, + ): + """syntax error; absolute path; str type; failing mode""" + datadir_path = fixture_cls_get_settings_dir_abs_path + tmp_path = fixture_func_create_tmp_dir_abs_path + ro_filename = PC_FULLNAME_SYNTAX2_ERROR + EXT_PYTHON_DISABLED + src_ro_filespec: Path = datadir_path / ro_filename + file_under_unit_test_filespec: Path = tmp_path / PC_FULLNAME_SYNTAX2_ERROR + + # Copy mangled pseudo-Python file into temporary area as a Python file + shutil.copyfile(src_ro_filespec, file_under_unit_test_filespec) + + with self._caplog.at_level(logging.DEBUG): + with pytest.raises(SyntaxError) as sample: + self._caplog.clear() + + load_source(path=str(file_under_unit_test_filespec), name="") # NOQA: RUF100 + # ignore return value due to SyntaxError exception + + assert "Invalid syntax" in self._caplog.text + assert sample.type == SyntaxError + assert sample.value.args[1]["lineno"] == UT_SYNTAX_ERROR2_LINENO + assert sample.value.args[1]["offset"] == UT_SYNTAX_ERROR2_OFFSET + + Path(file_under_unit_test_filespec).unlink(missing_ok=True) + + +if __name__ == "__main__": + # if executing this file alone, it tests this file alone. + # Can execute from any current working directory + pytest.main([__file__]) + + # more, complex variants of pytest. + # pytest.main([__file__, "-n0", "-rAw", "--capture=no", "--no-header"]) + # pytest.main([__file__, "-n0"]) # single-process, single-thread