Bugfix: Improve support for external theme packages#134
Bugfix: Improve support for external theme packages#134patdhlk merged 11 commits intouseblocks:mainfrom
Conversation
Refactor SCSS folder resolution to use a method that imports the theme module and retrieves the SCSS sources path, with a fallback to the bundled theme.
…es_path() convention
patdhlk
left a comment
There was a problem hiding this comment.
Good design, clean approach, addresses the right problem. Needs one more pass on the error boundaries. Thanks for the PR 🙏
To summarize:
- Catch
Exception, not justImportError— third-party code can fail in surprising ways - Validate the returned path exists (
os.path.isdir) before handing it to sass - Drop the version bump — let the maintainer handle that
- Consider normalizing with
os.path.abspath()or at least document that the return must be absolute - Add a brief comment on why
importlib.import_module(config_string)is acceptable here - Show evidence of a manual test with an actual external theme (<-- nice to have 👀 )
| """ | ||
| theme_name = self.app.config.simplepdf_theme or "simplepdf_theme" | ||
| try: | ||
| theme_module = importlib.import_module(theme_name) |
There was a problem hiding this comment.
You're importing an arbitrary Python module based on a config string. That's importlib.import_module(user_controlled_string). In practice the threat model is fine — anyone who can write conf.py already has code execution. But worth noting: simplepdf_theme is a config value that becomes a direct import. If this project ever grows a web-based config UI or accepts config from untrusted sources, this is an RCE. For now it's fine, but a one-line comment like # theme_name comes from conf.py, which is already arbitrary Python would tell the next person why this is OK.
| try: | ||
| theme_module = importlib.import_module(theme_name) | ||
| if hasattr(theme_module, "get_scss_sources_path"): | ||
| return theme_module.get_scss_sources_path() |
There was a problem hiding this comment.
You import the module, call one function, and trust whatever path it returns. What if get_scss_sources_path() returns a path that doesn't exist? Or
returns /etc/shadow? You feed it straight into sass.compile(dirname=(scss_folder, css_folder)). libsass will just fail with an opaque error.
One os.path.isdir() check after the call would turn a confusing sass traceback into a clear error message:
scss_folder = theme_module.get_scss_sources_path()
if not os.path.isdir(scss_folder):
raise ExtensionError(
f"Theme '{theme_name}' get_scss_sources_path() returned "
f"non-existent directory: {scss_folder}"
)
return scss_folder
Maybe that's the only validation we need. No need to overthink it.
| except ImportError: | ||
| logger.warning( | ||
| f"Could not import theme '{theme_name}', " | ||
| "falling back to bundled simplepdf_theme", | ||
| type="simplepdf", | ||
| subtype="theme", | ||
| ) |
There was a problem hiding this comment.
You catch ImportError but not Exception. If someone's theme __init__.py raises a ValueError or AttributeError during import, you get an unhandled crash instead of a fallback. Either catch Exception here (with a more specific log message) or document that theme modules must import cleanly.
I'd catch Exception. Theme code is third-party; be defensive at the boundary.
| [project] | ||
| name = "sphinx-simplepdf" | ||
| version = "1.7.0" | ||
| version = "1.8.0" |
There was a problem hiding this comment.
I'd leave it out of the PR and let the maintainer handle it. One less thing to merge-conflict on.
There was a problem hiding this comment.
The changelog entry is fine. No tests, but there's no test harness in this project yet (PR #121). What I would want to see is: did you actually test this with a real external theme? The PR description says this fixes the issue from PR #91 differently, but there's no evidence of a manual test. A screenshot or build log showing an external theme's SCSS being compiled would go a long way.
There was a problem hiding this comment.
The docs update is good. Clear contract: implement get_scss_sources_path(), return an absolute path. The example is minimal and correct. The suppress_warnings note is a nice touch.
One thing missing from the docs: what happens if get_scss_sources_path() returns a relative path? sass.compile might resolve it relative to cwd, which is sometimes unpredictable in Sphinx builds. The docstring says "absolute path" but you don't enforce it. Either os.path.abspath() the result or document that it must be absolute and let it fail loudly 🧨
There was a problem hiding this comment.
Addressed in 773595e: the builder now runs os.path.abspath on the return value and requires the path to be an existing directory before sass.compile
| Changelog | ||
| ========= | ||
|
|
||
| Release 1.8 |
There was a problem hiding this comment.
I'd leave it out of the PR and let the maintainer handle it. Mark/add it as unreleased
| type="simplepdf", | ||
| subtype="theme", | ||
| ) | ||
| from sphinx_simplepdf.themes.simplepdf_theme import get_scss_sources_path |
There was a problem hiding this comment.
This fallback import is at module scope inside a method. It works, but it's a little surprising. You already have importlib — why not just importlib.import_module("sphinx_simplepdf.themes.simplepdf_theme") for consistency?
Or just compute the path inline since you know exactly where it is. The late import buys you nothing here; this package is always installed.
|
Thanks for the review! I think addressed all the comments. |
patdhlk
left a comment
There was a problem hiding this comment.
Thanks. Good to go now.
Summary
The
SimplePdfBuilderresolves the SCSS sources folder via a hardcoded relative path to the bundledsimplepdf_theme. This means anyone using thesimplepdf_themeconfig option to point at an external theme still gets SCSS compiled from the bundled theme rather than their own.This PR adds a
_resolve_scss_folder()method that dynamically imports the theme module specified bysimplepdf_themeand calls itsget_scss_sources_path()function to locate the SCSS sources. If the configured theme cannot be imported or doesn't defineget_scss_sources_path(), the builder falls back to the bundledsimplepdf_theme.Changes
_resolve_scss_folder()method using import-based theme discoveryget_scss_sources_path()to the bundledsimplepdf_themeso it follows the same contract as external themestests/test_external_theme.pywith two integration tests:with_external_theme: stub theme package implementsget_scss_sources_path(); assertions include generated_static/main.csscontaining a test-only SCSS marker.with_theme_missing_scss_hook: stub theme has noget_scss_sources_path(); asserts the builder warning and successful PDF build (bundled SCSS fallback).docs preview build: https://sphinx-simplepdf--134.org.readthedocs.build/en/134/configuration.html#simplepdf-theme
Comparison with #91
PR #91 took a different approach: it moved SCSS compilation out of the builder and into the theme via the
builder-initedevent, with the goal of fully decoupling theme from builder. That approach had several issues raised in review, most notably:static/styles/sources/directory with SCSS, breaking themes that only useapp.add_css_file()to override stylessimplepdf_varsandsimplepdf_theme_optionsregistration into the theme, fragmenting configurationThis PR takes a simpler approach: keep SCSS compilation in the builder where it already lives, but make the SCSS source path resolution dynamic. Theme authors explicitly opt in by defining
get_scss_sources_path()— there's no assumed directory structure and no restructuring of responsibilities. Themes that don't define it get a clear warning rather than silent breakage.I think this is the better approach since theme's with the purpose of creating PDFs are more likely to be tightly coupled with the simplePDF extension.
Backward Compatibility
This change is fully backward-compatible. When
simplepdf_themeis set to the default"simplepdf_theme", the bundled theme'sget_scss_sources_path()is called and resolves to the same path as before.