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)
This commit is contained in:
egberts 2024-07-14 15:04:34 -05:00
commit 764eefcf04
10 changed files with 2125 additions and 174 deletions

View file

@ -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 typing import Any, Dict, Optional
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 @@ DEFAULT_CONFIG = {
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.<module_name>".
: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 read_settings(
]
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 read_settings(
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."""
def get_settings_from_file(path: str, reload: bool) -> Settings:
"""Loads module from a file then clones dictionary of settings from that module.
name, ext = os.path.splitext(os.path.basename(path))
module = load_source(name, path)
: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,

View file

@ -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"

View file

@ -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"

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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"

View file

@ -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 @@ class TestSettingsConfiguration(unittest.TestCase):
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 @@ class TestSettingsConfiguration(unittest.TestCase):
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 @@ class TestSettingsConfiguration(unittest.TestCase):
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 @@ class TestSettingsConfiguration(unittest.TestCase):
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()

View file

@ -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()

File diff suppressed because it is too large Load diff

View file

@ -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