diff --git a/.coveragerc b/.coveragerc index fdd2cad6..6347d80b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,4 @@ [report] -omit = pelican/tests/* +omit = + pelican/tests/* + pelican/signals.py diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 0d92c9d9..ac9f882e 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -5,3 +5,15 @@ cabdb26cee66e1173cf16cb31d3fe5f9fa4392e7 ecd598f293161a52564aa6e8dfdcc8284dc93970 # Apply Ruff and pyupgrade to Jinja templates db241feaa445375dc05e189e69287000ffe5fa8e +# Change pre-commit to run ruff and ruff-format with fixes +6d8597addb17d5fa3027ead91427939e8e4e89ec +# Upgrade Ruff from 0.1.x to 0.4.x +0bd02c00c078fe041b65fbf4eab13601bb42676d +# Apply more Ruff checks to code +9d30c5608a58d202b1c02d55651e6ac746bfb173 +# Apply yet more Ruff checks to code +7577dd7603f7cb3a09922d1edb65b6eafb6e2ac7 +# Indent Jinja templates, HTML, CSS, and JS via DjHTML +4af40e80772a58eac8969360e5caeb99e3e26e78 +# Ruff UP031: Use F-string format specifiers instead of percent format +30bde3823f50b9ba8ac5996c1c46bb72031aa6b8 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..0fbd850c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +.github/workflows/github_pages.yml @seanh diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a0a90b94 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# See https://docs.github.com/en/free-pro-team@latest/ +# github/administering-a-repository/enabling-and-disabling-version-updates + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/github_pages.yml b/.github/workflows/github_pages.yml index ccf172b4..48ebb2d0 100644 --- a/.github/workflows/github_pages.yml +++ b/.github/workflows/github_pages.yml @@ -1,3 +1,4 @@ +# Workflow for publishing to GitHub Pages. name: Deploy to GitHub Pages on: workflow_call: @@ -16,6 +17,26 @@ on: default: "output/" description: "Where to output the generated files (`pelican`'s `--output` option)" type: string + theme: + required: false + default: "" + description: "The GitHub repo URL of a custom theme to use, for example: 'https://github.com/seanh/sidecar.git'" + type: string + python: + required: false + default: "3.12" + description: "The version of Python to use, for example: '3.12' (to use the most recent version of Python 3.12, this is faster) or '3.12.1' (to use an exact version, slower)" + type: string + siteurl: + required: false + default: "" + description: "The base URL of your web site (Pelican's SITEURL setting). If not passed this will default to the URL of your GitHub Pages site, which is correct in most cases." + type: string + feed_domain: + required: false + default: "" + description: "The domain to be prepended to feed URLs (Pelican's FEED_DOMAIN setting). If not passed this will default to the URL of your GitHub Pages site, which is correct in most cases." + type: string permissions: contents: read pages: write @@ -28,24 +49,42 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: ${{ inputs.python }} + - name: Checkout theme + if: ${{ inputs.theme }} + run: git clone '${{ inputs.theme }}' .theme - name: Configure GitHub Pages id: pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v5 - name: Install requirements run: pip install ${{ inputs.requirements }} - name: Build Pelican site + shell: python run: | - pelican \ - --settings "${{ inputs.settings }}" \ - --extra-settings SITEURL='"${{ steps.pages.outputs.base_url }}"' \ - --output "${{ inputs.output-path }}" + import subprocess + + cmd = "pelican" + cmd += " --settings ${{ inputs.settings }}" + cmd += " --extra-settings" + cmd += """ SITEURL='"${{ inputs.siteurl || steps.pages.outputs.base_url }}"'""" + cmd += """ FEED_DOMAIN='"${{ inputs.feed_domain || steps.pages.outputs.base_url }}"'""" + cmd += " --output ${{ inputs.output-path }}" + + if "${{ inputs.theme }}": + cmd += " --theme-path .theme" + + subprocess.run(cmd, shell=True, check=True) + - name: Fix permissions + run: | + chmod -c -R +rX "${{ inputs.output-path }}" | while read line; do + echo "::warning title=Invalid file permissions automatically fixed::$line" + done - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: path: ${{ inputs.output-path }} deploy: @@ -57,4 +96,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c29a08c2..e07caa72 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,9 +23,9 @@ jobs: python: "3.9" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} cache: "pip" @@ -52,10 +52,10 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: pdm-project/setup-pdm@v3 + - uses: actions/checkout@v4 + - uses: pdm-project/setup-pdm@v4 with: - python-version: 3.9 + python-version: "3.11" cache: true cache-dependency-path: ./pyproject.toml - name: Install dependencies @@ -64,16 +64,16 @@ jobs: - name: Run linters run: pdm lint --diff - name: Run pre-commit checks on all files - uses: pre-commit/action@v3.0.0 + uses: pre-commit/action@v3.0.1 build: name: Test build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: pdm-project/setup-pdm@v3 + - uses: actions/checkout@v4 + - uses: pdm-project/setup-pdm@v4 with: - python-version: 3.9 + python-version: "3.11" cache: true cache-dependency-path: ./pyproject.toml - name: Install dependencies @@ -88,11 +88,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" cache: "pip" cache-dependency-path: "**/requirements/*" - name: Install tox @@ -100,7 +100,7 @@ jobs: - name: Check run: tox -e docs - name: cache the docs for inspection - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: docs path: docs/_build/html/ @@ -110,21 +110,21 @@ jobs: environment: Deployment needs: [test, lint, docs, build] runs-on: ubuntu-latest - if: github.ref=='refs/heads/master' && github.event_name!='pull_request' && github.repository == 'getpelican/pelican' + if: github.ref=='refs/heads/main' && github.event_name!='pull_request' && github.repository == 'getpelican/pelican' permissions: contents: write id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: token: ${{ secrets.GH_TOKEN }} - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Check release id: check_release diff --git a/.gitignore b/.gitignore index 473efea2..65e4d759 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ samples/output *.lock .pdm-python .venv + +# direnv +.envrc + +# pycharm +.idea diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 333bc3c0..9ae674ab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ # See https://pre-commit.com/hooks.html for info on hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-added-large-files - id: check-ast @@ -13,11 +13,18 @@ repos: - id: end-of-file-fixer - id: forbid-new-submodules - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.5 + # ruff version should match the one in pyproject.toml + rev: v0.4.10 hooks: - id: ruff + args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - args: ["--check"] -exclude: ^pelican/tests/output/ + - repo: https://github.com/rtts/djhtml + rev: '3.0.6' + hooks: + - id: djhtml + - id: djcss + - id: djjs diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 4faace91..5ac5f0ad 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -24,7 +24,7 @@ Before you ask for help, please make sure you do the following: 3. Try reproducing the issue in a clean environment, ensuring you are using: -* latest Pelican release (or an up-to-date Git clone of Pelican master) +* latest Pelican release (or an up-to-date Git clone of Pelican ``main`` branch) * latest releases of libraries used by Pelican * no plugins or only those related to the issue @@ -87,7 +87,7 @@ Using Git and GitHub -------------------- * `Create a new branch`_ specific to your change (as opposed to making - your commits in the master branch). + your commits in the ``main`` branch). * **Don't put multiple unrelated fixes/features in the same branch / pull request.** For example, if you're working on a new feature and find a bugfix that doesn't *require* your new feature, **make a new distinct branch and pull diff --git a/README.rst b/README.rst index 3f708242..0e3577ec 100644 --- a/README.rst +++ b/README.rst @@ -64,8 +64,8 @@ Why the name “Pelican”? .. _`Pelican's internals`: https://docs.getpelican.com/en/latest/internals.html .. _`hosted on GitHub`: https://github.com/getpelican/pelican -.. |build-status| image:: https://img.shields.io/github/actions/workflow/status/getpelican/pelican/main.yml?branch=master - :target: https://github.com/getpelican/pelican/actions/workflows/main.yml?query=branch%3Amaster +.. |build-status| image:: https://img.shields.io/github/actions/workflow/status/getpelican/pelican/main.yml?branch=main + :target: https://github.com/getpelican/pelican/actions/workflows/main.yml?query=branch%3Amain :alt: GitHub Actions CI: continuous integration status .. |pypi-version| image:: https://img.shields.io/pypi/v/pelican.svg :target: https://pypi.org/project/pelican/ diff --git a/docs/_templates/page.html b/docs/_templates/page.html index 233f43ad..0fbfdf7d 100644 --- a/docs/_templates/page.html +++ b/docs/_templates/page.html @@ -1,201 +1,201 @@ {% extends "base.html" %} {% block body -%} -{{ super() }} -{% include "partials/icons.html" %} + {{ super() }} + {% include "partials/icons.html" %} - - - - + + + + -{% if theme_announcement -%} -
- -
-{%- endif %} + {% if theme_announcement -%} +
+ +
+ {%- endif %} -
-
-
- -
- -
-
- +
+
+
+
- -
-
- -
-
-
- - - - - {% trans %}Back to top{% endtrans %} - -
- {% if theme_top_of_page_button == "edit" -%} - {%- include "components/edit-this-page.html" with context -%} - {%- elif theme_top_of_page_button != None -%} - {{ warning("Got an unsupported value for 'top_of_page_button'") }} - {%- endif -%} - {#- Theme toggle -#} -
- -
- +
+
+
-
- {% block content %}{{ body }}{% endblock %} -
+
-
- {% block footer %} - -
-
- {%- if show_copyright %} - - {%- endif %} - {%- if last_updated -%} -
- {% trans last_updated=last_updated|e -%} - Last updated on {{ last_updated }} - {%- endtrans -%} -
- {%- endif %} + +
-
- +
+
+
+ + + + + {% trans %}Back to top{% endtrans %} + +
+ {% if theme_top_of_page_button == "edit" -%} + {%- include "components/edit-this-page.html" with context -%} + {%- elif theme_top_of_page_button != None -%} + {{ warning("Got an unsupported value for 'top_of_page_button'") }} + {%- endif -%} + {#- Theme toggle -#} +
+ +
+ +
+
+ {% block content %}{{ body }}{% endblock %} +
+
+ +
+ +
-
{%- endblock %} diff --git a/docs/conf.py b/docs/conf.py index 8d8078a2..b812ee4c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,7 +21,6 @@ with open("../pyproject.toml", "rb") as f: templates_path = ["_templates"] extensions = [ "sphinx.ext.autodoc", - "sphinx.ext.ifconfig", "sphinx.ext.extlinks", "sphinxext.opengraph", ] @@ -29,7 +28,7 @@ source_suffix = ".rst" master_doc = "index" project = project_data.get("name").upper() year = datetime.datetime.now().date().year -copyright = f"2010–{year}" +copyright = f"2010–{year}" # noqa: RUF001 exclude_patterns = ["_build"] release = project_data.get("version") version = ".".join(release.split(".")[:1]) diff --git a/docs/content.rst b/docs/content.rst index dcb99309..4a33a06e 100644 --- a/docs/content.rst +++ b/docs/content.rst @@ -442,8 +442,8 @@ For **Markdown**, one must rely on an extension. For example, using the `mdx_inc Importing an existing site ========================== -It is possible to import your site from WordPress, Tumblr, Dotclear, and RSS -feeds using a simple script. See :ref:`import`. +It is possible to import your site from several other blogging sites +(like WordPress, Tumblr, ..) using a simple script. See :ref:`import`. Translations ============ @@ -634,7 +634,7 @@ are not included by default in tag, category, and author indexes, nor in the main article feed. This has the effect of creating an "unlisted" post. .. _W3C ISO 8601: https://www.w3.org/TR/NOTE-datetime -.. _AsciiDoc: https://www.methods.co.nz/asciidoc/ +.. _AsciiDoc: https://asciidoc.org .. _Pelican Plugins: https://github.com/pelican-plugins .. _pelican-plugins: https://github.com/getpelican/pelican-plugins .. _Python-Markdown: https://github.com/Python-Markdown/markdown diff --git a/docs/contribute.rst b/docs/contribute.rst index 6a5a417e..07cf998a 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -64,6 +64,27 @@ your bug fix or feature:: Now you can make changes to Pelican, its documentation, and/or other aspects of the project. +Setting up ``git blame`` (optional) +----------------------------------- + +``git blame`` annotates lines in a file with information about the pull request +that last modified it. Sweeping shallow changes (like formatting) can make that +information less useful, so we keep a list of such changes to be ignored. Run the +following command to set this up in your repository, adding ``--global`` if you +want this setting to apply to all repositories:: + + git config blame.ignoreRevsFile .git-blame-ignore-revs + +As noted in a `useful article`_ about ``git blame``, there are other related +settings you may find to be beneficial:: + + # Add `?` to any lines that have had a commit skipped using --ignore-rev + git config --global blame.markIgnoredLines true + # Add `*` to any lines that were added in a skipped commit and can not be attributed + git config --global blame.markUnblamableLines true + +.. _useful article: https://www.michaelheap.com/git-ignore-rev/ + Running the test suite ---------------------- @@ -108,6 +129,21 @@ environments. .. _Tox: https://tox.readthedocs.io/en/latest/ +Running a code coverage report +------------------------------ + +Code is more likely to stay robust if it is tested. Coverage_ is a library that +measures how much of the code is tested. To run it:: + + invoke coverage + +This will show overall coverage, coverage per file, and even line-by-line coverage. +There is also an HTML report available:: + + open htmlcov/index.html + +.. _Coverage: https://github.com/nedbat/coveragepy + Building the docs ----------------- diff --git a/docs/importer.rst b/docs/importer.rst index 997a4632..49d6db24 100644 --- a/docs/importer.rst +++ b/docs/importer.rst @@ -11,6 +11,7 @@ software to reStructuredText or Markdown. The supported import formats are: - Blogger XML export - Dotclear export +- Medium export - Tumblr API - WordPress XML export - RSS/Atom feed @@ -26,6 +27,12 @@ not be converted (as Pelican also supports Markdown). manually, or use a plugin such as `More Categories`_ that enables multiple categories per article. +.. note:: + + Imported pages may contain links to images that still point to the original site. + So you might want to download those images into your local content and manually + re-link them from the relevant pages of your site. + Dependencies ============ @@ -65,6 +72,7 @@ Optional arguments -h, --help Show this help message and exit --blogger Blogger XML export (default: False) --dotclear Dotclear export (default: False) + --medium Medium export (default: False) --tumblr Tumblr API (default: False) --wpfile WordPress XML export (default: False) --feed Feed to parse (default: False) @@ -80,8 +88,7 @@ Optional arguments (default: False) --filter-author Import only post from the specified author --strip-raw Strip raw HTML code that can't be converted to markup - such as flash embeds or iframes (wordpress import - only) (default: False) + such as flash embeds or iframes (default: False) --wp-custpost Put wordpress custom post types in directories. If used with --dir-cat option directories will be created as "/post_type/category/" (wordpress import only) @@ -113,6 +120,14 @@ For Dotclear:: $ pelican-import --dotclear -o ~/output ~/backup.txt +For Medium:: + + $ pelican-import --medium -o ~/output ~/medium-export/posts/ + +The Medium export is a zip file. Unzip it, and point this tool to the +"posts" subdirectory. For more information on how to export, see +https://help.medium.com/hc/en-us/articles/115004745787-Export-your-account-data. + For Tumblr:: $ pelican-import --tumblr -o ~/output --blogname= @@ -121,6 +136,15 @@ For WordPress:: $ pelican-import --wpfile -o ~/output ~/posts.xml +For Medium (an example of using an RSS feed): + + $ python -m pip install feedparser + $ pelican-import --feed https://medium.com/feed/@username + +.. note:: + + The RSS feed may only return the most recent posts — not all of them. + Tests ===== diff --git a/docs/index.rst b/docs/index.rst index 60591482..159ea3be 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,15 +1,6 @@ Pelican |release| ================= - -.. ifconfig:: release.endswith('.dev') - - .. warning:: - - This documentation is for the version of Pelican currently under - development. Were you looking for version |last_stable| documentation? - - Pelican is a static site generator, written in Python_. Highlights include: * Write your content directly with your editor of choice in reStructuredText_ diff --git a/docs/settings.rst b/docs/settings.rst index a8cf4950..43da1140 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -28,7 +28,7 @@ Environment variables can also be used here but must be escaped appropriately:: Settings are configured in the form of a Python module (a file). There is an `example settings file -`_ +`_ available for reference. To see a list of current settings in your environment, including both default @@ -1016,6 +1016,11 @@ the ``TAG_FEED_ATOM`` and ``TAG_FEED_RSS`` settings: to ``False``, the full content will be included instead. This setting doesn't affect Atom feeds, only RSS ones. +.. data:: FEED_APPEND_REF = False + + If set to ``True``, ``?ref=feed`` will be appended to links in generated + feeds for the purpose of referrer tracking. + If you don't want to generate some or any of these feeds, set the above variables to ``None``. diff --git a/docs/themes.rst b/docs/themes.rst index 2e01ec8e..ace5dcb9 100644 --- a/docs/themes.rst +++ b/docs/themes.rst @@ -17,7 +17,7 @@ To generate its HTML output, Pelican uses the `Jinja `_ templating engine due to its flexibility and straightforward syntax. If you want to create your own theme, feel free to take inspiration from the `"simple" theme -`_. +`_. To generate your site using a theme you have created (or downloaded manually and then modified), you can specify that theme via the ``-t`` flag:: @@ -368,7 +368,7 @@ period_num A tuple of the form (``year``, ``month``, ``day``), You can see an example of how to use `period` in the `"simple" theme period_archives.html template -`_. +`_. .. _period_archives_variable: diff --git a/docs/tips.rst b/docs/tips.rst index 904e5ee7..e5574c7c 100644 --- a/docs/tips.rst +++ b/docs/tips.rst @@ -89,18 +89,18 @@ Publishing a User Site to GitHub Pages from a Branch ---------------------------------------------------- To publish a Pelican site in the form of User Pages, you need to *push* the -content of the ``output`` dir generated by Pelican to the ``master`` branch of +content of the ``output`` dir generated by Pelican to the ``main`` branch of your ``.github.io`` repository on GitHub. Again, you can take advantage of ``ghp-import``:: $ pelican content -o output -s pelicanconf.py $ ghp-import output -b gh-pages - $ git push git@github.com:elemoine/elemoine.github.io.git gh-pages:master + $ git push git@github.com:elemoine/elemoine.github.io.git gh-pages:main The ``git push`` command pushes the local ``gh-pages`` branch (freshly updated by the ``ghp-import`` command) to the ``elemoine.github.io`` repository's -``master`` branch on GitHub. +``main`` branch on GitHub. .. note:: @@ -116,18 +116,19 @@ inside the ``Pelican`` folder you can run:: $ pelican content -o .. -s pelicanconf.py -Now you can push the whole project ``.github.io`` to the master +Now you can push the whole project ``.github.io`` to the main branch of your GitHub repository:: - $ git push origin master + $ git push origin main (assuming origin is set to your remote repository). Publishing to GitHub Pages Using a Custom GitHub Actions Workflow ----------------------------------------------------------------- -Pelican comes with a `custom workflow `_ -for publishing a Pelican site. To use it: +Pelican-powered sites can be published to GitHub Pages via a `custom workflow +`_. +To use it: 1. Enable GitHub Pages in your repo: go to **Settings → Pages** and choose **GitHub Actions** for the **Source** setting. @@ -143,7 +144,7 @@ for publishing a Pelican site. To use it: workflow_dispatch: jobs: deploy: - uses: "getpelican/pelican/.github/workflows/github_pages.yml@master" + uses: "getpelican/pelican/.github/workflows/github_pages.yml@main" permissions: contents: "read" pages: "write" @@ -151,6 +152,25 @@ for publishing a Pelican site. To use it: with: settings: "publishconf.py" + You may want to replace the ``@main`` with the ID of a specific commit in + this repo in order to pin the version of the reusable workflow that you're using: + ``uses: getpelican/pelican/.github/workflows/github_pages.yml@``. + If you do this you might want to get Dependabot to send you automated pull + requests to update that commit ID whenever new versions of this workflow are + published, like so: + + .. code-block:: yaml + + # .github/dependabot.yml + version: 2 + updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + + See `GitHub's docs about using Dependabot to keep your actions up to date `_. + 3. Go to the **Actions** tab in your repo (``https://github.com///actions``) and you should see a **Deploy to GitHub Pages** action running. @@ -162,8 +182,8 @@ for publishing a Pelican site. To use it: Notes: -* You don't need to set ``SITEURL`` in your Pelican settings: the workflow will - set it for you +* You don't need to set ``SITEURL`` or ``FEED_DOMAIN`` in your Pelican + settings: the workflow will set them correctly for you * You don't need to commit your ``--output`` / ``OUTPUT_PATH`` directory (``output/``) to git: the workflow will run ``pelican`` to build the output @@ -173,36 +193,72 @@ See `GitHub's docs about reusable workflows )") + signals.all_generators_finalized.send(generators) + + # update links in the summary, etc for p in generators: if hasattr(p, "refresh_metadata_intersite_links"): p.refresh_metadata_intersite_links() - signals.all_generators_finalized.send(generators) - writer = self._get_writer() for p in generators: @@ -183,15 +194,7 @@ class Pelican: ) console.print( - "Done: Processed {}, {}, {}, {}, {} and {} in {:.2f} seconds.".format( - pluralized_articles, - pluralized_drafts, - pluralized_hidden_articles, - pluralized_pages, - pluralized_hidden_pages, - pluralized_draft_pages, - time.time() - start_time, - ) + f"Done: Processed {pluralized_articles}, {pluralized_drafts}, {pluralized_hidden_articles}, {pluralized_pages}, {pluralized_hidden_pages} and {pluralized_draft_pages} in {time.time() - start_time:.2f} seconds." ) def _get_generator_classes(self): @@ -292,7 +295,7 @@ class ParseOverrides(argparse.Action): raise ValueError( "Extra settings must be specified as KEY=VALUE pairs " f"but you specified {item}" - ) + ) from None try: overrides[k] = json.loads(v) except json.decoder.JSONDecodeError: @@ -303,7 +306,7 @@ class ParseOverrides(argparse.Action): "Use -e KEY='\"string\"' to specify a string value; " "-e KEY=null to specify None; " "-e KEY=false (or true) to specify False (or True)." - ) + ) from None setattr(namespace, self.dest, overrides) @@ -344,8 +347,8 @@ def parse_arguments(argv=None): "--settings", dest="settings", help="The settings of the application, this is " - "automatically set to {} if a file exists with this " - "name.".format(DEFAULT_CONFIG_NAME), + f"automatically set to {DEFAULT_CONFIG_NAME} if a file exists with this " + "name.", ) parser.add_argument( @@ -415,7 +418,7 @@ def parse_arguments(argv=None): "--relative-urls", dest="relative_paths", action="store_true", - help="Use relative urls in output, " "useful for site development", + help="Use relative urls in output, useful for site development", ) parser.add_argument( @@ -431,7 +434,7 @@ def parse_arguments(argv=None): "--ignore-cache", action="store_true", dest="ignore_cache", - help="Ignore content cache " "from previous runs by not loading cache files.", + help="Ignore content cache from previous runs by not loading cache files.", ) parser.add_argument( @@ -445,6 +448,17 @@ def parse_arguments(argv=None): ), ) + LOG_HANDLERS = {"plain": None, "rich": DEFAULT_LOG_HANDLER} + parser.add_argument( + "--log-handler", + default="rich", + choices=LOG_HANDLERS, + help=( + "Which handler to use to format log messages. " + "The `rich` handler prints output in columns." + ), + ) + parser.add_argument( "--logs-dedup-min-level", default="WARNING", @@ -475,7 +489,7 @@ def parse_arguments(argv=None): "-b", "--bind", dest="bind", - help="IP to bind to when serving files via HTTP " "(default: 127.0.0.1)", + help="IP to bind to when serving files via HTTP (default: 127.0.0.1)", ) parser.add_argument( @@ -499,6 +513,8 @@ def parse_arguments(argv=None): if args.bind is not None and not args.listen: logger.warning("--bind without --listen has no effect") + args.log_handler = LOG_HANDLERS[args.log_handler] + return args @@ -558,7 +574,7 @@ def autoreload(args, excqueue=None): try: pelican.run() - changed_files = wait_for_changes(args.settings, Readers, settings) + changed_files = wait_for_changes(args.settings, settings) changed_files = {c[1] for c in changed_files} if settings_file in changed_files: @@ -621,6 +637,7 @@ def main(argv=None): level=args.verbosity, fatal=args.fatal, name=__name__, + handler=args.log_handler, logs_dedup_min_level=logs_dedup_min_level, ) diff --git a/pelican/__main__.py b/pelican/__main__.py index 17aead3b..41a1f712 100644 --- a/pelican/__main__.py +++ b/pelican/__main__.py @@ -4,6 +4,5 @@ python -m pelican module entry point to run via python -m from . import main - if __name__ == "__main__": main() diff --git a/pelican/contents.py b/pelican/contents.py index fc90edeb..5a403261 100644 --- a/pelican/contents.py +++ b/pelican/contents.py @@ -6,7 +6,8 @@ import os import re from datetime import timezone from html import unescape -from urllib.parse import unquote, urljoin, urlparse, urlunparse +from typing import Any, Dict, Optional, Set, Tuple +from urllib.parse import ParseResult, unquote, urljoin, urlparse, urlunparse try: from zoneinfo import ZoneInfo @@ -15,7 +16,10 @@ except ModuleNotFoundError: from pelican.plugins import signals -from pelican.settings import DEFAULT_CONFIG +from pelican.settings import DEFAULT_CONFIG, Settings + +# Import these so that they're available when you import from pelican.contents. +from pelican.urlwrappers import Author, Category, Tag, URLWrapper # NOQA from pelican.utils import ( deprecated_attribute, memoized, @@ -28,9 +32,6 @@ from pelican.utils import ( truncate_html_words, ) -# Import these so that they're available when you import from pelican.contents. -from pelican.urlwrappers import Author, Category, Tag, URLWrapper # NOQA - logger = logging.getLogger(__name__) @@ -45,12 +46,20 @@ class Content: """ + default_template: Optional[str] = None + mandatory_properties: Tuple[str, ...] = () + @deprecated_attribute(old="filename", new="source_path", since=(3, 2, 0)) def filename(): return None def __init__( - self, content, metadata=None, settings=None, source_path=None, context=None + self, + content: str, + metadata: Optional[Dict[str, Any]] = None, + settings: Optional[Settings] = None, + source_path: Optional[str] = None, + context: Optional[Dict[Any, Any]] = None, ): if metadata is None: metadata = {} @@ -64,7 +73,7 @@ class Content: self._context = context self.translations = [] - local_metadata = dict() + local_metadata = {} local_metadata.update(metadata) # set metadata as attributes @@ -157,10 +166,10 @@ class Content: signals.content_object_init.send(self) - def __str__(self): + def __str__(self) -> str: return self.source_path or repr(self) - def _has_valid_mandatory_properties(self): + def _has_valid_mandatory_properties(self) -> bool: """Test mandatory properties are set.""" for prop in self.mandatory_properties: if not hasattr(self, prop): @@ -170,7 +179,7 @@ class Content: return False return True - def _has_valid_save_as(self): + def _has_valid_save_as(self) -> bool: """Return true if save_as doesn't write outside output path, false otherwise.""" try: @@ -191,7 +200,7 @@ class Content: return True - def _has_valid_status(self): + def _has_valid_status(self) -> bool: if hasattr(self, "allowed_statuses"): if self.status not in self.allowed_statuses: logger.error( @@ -205,7 +214,7 @@ class Content: # if undefined we allow all return True - def is_valid(self): + def is_valid(self) -> bool: """Validate Content""" # Use all() to not short circuit and get results of all validations return all( @@ -217,7 +226,7 @@ class Content: ) @property - def url_format(self): + def url_format(self) -> Dict[str, Any]: """Returns the URL, formatted with the proper values""" metadata = copy.copy(self.metadata) path = self.metadata.get("path", self.get_relative_source_path()) @@ -233,19 +242,19 @@ class Content: ) return metadata - def _expand_settings(self, key, klass=None): + def _expand_settings(self, key: str, klass: Optional[str] = None) -> str: if not klass: klass = self.__class__.__name__ fq_key = (f"{klass}_{key}").upper() return str(self.settings[fq_key]).format(**self.url_format) - def get_url_setting(self, key): + def get_url_setting(self, key: str) -> str: if hasattr(self, "override_" + key): return getattr(self, "override_" + key) - key = key if self.in_default_lang else "lang_%s" % key + key = key if self.in_default_lang else f"lang_{key}" return self._expand_settings(key) - def _link_replacer(self, siteurl, m): + def _link_replacer(self, siteurl: str, m: re.Match) -> str: what = m.group("what") value = urlparse(m.group("value")) path = value.path @@ -273,15 +282,15 @@ class Content: # XXX Put this in a different location. if what in {"filename", "static", "attach"}: - def _get_linked_content(key, url): + def _get_linked_content(key: str, url: ParseResult) -> Optional[Content]: nonlocal value - def _find_path(path): + def _find_path(path: str) -> Optional[Content]: if path.startswith("/"): path = path[1:] else: # relative to the source path of this content - path = self.get_relative_source_path( + path = self.get_relative_source_path( # type: ignore os.path.join(self.relative_dir, path) ) return self._context[key].get(path, None) @@ -325,7 +334,7 @@ class Content: linked_content = _get_linked_content(key, value) if linked_content: if what == "attach": - linked_content.attach_to(self) + linked_content.attach_to(self) # type: ignore origin = joiner(siteurl, linked_content.url) origin = origin.replace("\\", "/") # for Windows paths. else: @@ -349,7 +358,7 @@ class Content: origin = joiner(siteurl, Author(path, self.settings).url) else: logger.warning( - "Replacement Indicator '%s' not recognized, " "skipping replacement", + "Replacement Indicator '%s' not recognized, skipping replacement", what, ) @@ -360,18 +369,18 @@ class Content: return "".join((m.group("markup"), m.group("quote"), origin, m.group("quote"))) - def _get_intrasite_link_regex(self): + def _get_intrasite_link_regex(self) -> re.Pattern: intrasite_link_regex = self.settings["INTRASITE_LINK_REGEX"] - regex = r""" + regex = rf""" (?P<[^\>]+ # match tag with all url-value attributes (?:href|src|poster|data|cite|formaction|action|content)\s*=\s*) (?P["\']) # require value to be quoted - (?P{}(?P.*?)) # the url value - (?P=quote)""".format(intrasite_link_regex) + (?P{intrasite_link_regex}(?P.*?)) # the url value + (?P=quote)""" return re.compile(regex, re.X) - def _update_content(self, content, siteurl): + def _update_content(self, content: str, siteurl: str) -> str: """Update the content attribute. Change all the relative paths of the content to relative paths @@ -387,7 +396,7 @@ class Content: hrefs = self._get_intrasite_link_regex() return hrefs.sub(lambda m: self._link_replacer(siteurl, m), content) - def get_static_links(self): + def get_static_links(self) -> Set[str]: static_links = set() hrefs = self._get_intrasite_link_regex() for m in hrefs.finditer(self._content): @@ -403,15 +412,15 @@ class Content: path = self.get_relative_source_path( os.path.join(self.relative_dir, path) ) - path = path.replace("%20", " ") + path = path.replace("%20", " ") # type: ignore static_links.add(path) return static_links - def get_siteurl(self): + def get_siteurl(self) -> str: return self._context.get("localsiteurl", "") @memoized - def get_content(self, siteurl): + def get_content(self, siteurl: str) -> str: if hasattr(self, "_get_content"): content = self._get_content() else: @@ -419,11 +428,11 @@ class Content: return self._update_content(content, siteurl) @property - def content(self): + def content(self) -> str: return self.get_content(self.get_siteurl()) @memoized - def get_summary(self, siteurl): + def get_summary(self, siteurl: str) -> str: """Returns the summary of an article. This is based on the summary metadata if set, otherwise truncate the @@ -447,10 +456,10 @@ class Content: ) @property - def summary(self): + def summary(self) -> str: return self.get_summary(self.get_siteurl()) - def _get_summary(self): + def _get_summary(self) -> str: """deprecated function to access summary""" logger.warning( @@ -460,34 +469,35 @@ class Content: return self.summary @summary.setter - def summary(self, value): + def summary(self, value: str): """Dummy function""" - pass @property - def status(self): + def status(self) -> str: return self._status @status.setter - def status(self, value): + def status(self, value: str) -> None: # TODO maybe typecheck self._status = value.lower() @property - def url(self): + def url(self) -> str: return self.get_url_setting("url") @property - def save_as(self): + def save_as(self) -> str: return self.get_url_setting("save_as") - def _get_template(self): + def _get_template(self) -> str: if hasattr(self, "template") and self.template is not None: return self.template else: return self.default_template - def get_relative_source_path(self, source_path=None): + def get_relative_source_path( + self, source_path: Optional[str] = None + ) -> Optional[str]: """Return the relative path (from the content path) to the given source_path. @@ -507,7 +517,7 @@ class Content: ) @property - def relative_dir(self): + def relative_dir(self) -> str: return posixize_path( os.path.dirname( os.path.relpath( @@ -517,7 +527,7 @@ class Content: ) ) - def refresh_metadata_intersite_links(self): + def refresh_metadata_intersite_links(self) -> None: for key in self.settings["FORMATTED_FIELDS"]: if key in self.metadata and key != "summary": value = self._update_content(self.metadata[key], self.get_siteurl()) @@ -525,13 +535,16 @@ class Content: setattr(self, key.lower(), value) # _summary is an internal variable that some plugins may be writing to, - # so ensure changes to it are picked up - if ( - "summary" in self.settings["FORMATTED_FIELDS"] - and "summary" in self.metadata - ): - self._summary = self._update_content(self._summary, self.get_siteurl()) - self.metadata["summary"] = self._summary + # so ensure changes to it are picked up, and write summary back to it + if "summary" in self.settings["FORMATTED_FIELDS"]: + if hasattr(self, "_summary"): + self.metadata["summary"] = self._summary + + if "summary" in self.metadata: + self.metadata["summary"] = self._update_content( + self.metadata["summary"], self.get_siteurl() + ) + self._summary = self.metadata["summary"] class Page(Content): @@ -540,7 +553,7 @@ class Page(Content): default_status = "published" default_template = "page" - def _expand_settings(self, key): + def _expand_settings(self, key: str) -> str: klass = "draft_page" if self.status == "draft" else None return super()._expand_settings(key, klass) @@ -567,7 +580,7 @@ class Article(Content): if not hasattr(self, "date") and self.status == "draft": self.date = datetime.datetime.max.replace(tzinfo=self.timezone) - def _expand_settings(self, key): + def _expand_settings(self, key: str) -> str: klass = "draft" if self.status == "draft" else "article" return super()._expand_settings(key, klass) @@ -577,7 +590,7 @@ class Static(Content): default_status = "published" default_template = None - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._output_location_referenced = False @@ -594,18 +607,18 @@ class Static(Content): return None @property - def url(self): + def url(self) -> str: # Note when url has been referenced, so we can avoid overriding it. self._output_location_referenced = True return super().url @property - def save_as(self): + def save_as(self) -> str: # Note when save_as has been referenced, so we can avoid overriding it. self._output_location_referenced = True return super().save_as - def attach_to(self, content): + def attach_to(self, content: Content) -> None: """Override our output directory with that of the given content object.""" # Determine our file's new output path relative to the linking @@ -630,7 +643,7 @@ class Static(Content): new_url = path_to_url(new_save_as) - def _log_reason(reason): + def _log_reason(reason: str) -> None: logger.warning( "The {attach} link in %s cannot relocate " "%s because %s. Falling back to " diff --git a/pelican/generators.py b/pelican/generators.py index 3b5ca9e4..548c494f 100644 --- a/pelican/generators.py +++ b/pelican/generators.py @@ -7,6 +7,7 @@ from collections import defaultdict from functools import partial from itertools import chain, groupby from operator import attrgetter +from typing import List, Optional, Set from jinja2 import ( BaseLoader, @@ -156,7 +157,9 @@ class Generator: return False - def get_files(self, paths, exclude=[], extensions=None): + def get_files( + self, paths, exclude: Optional[List[str]] = None, extensions=None + ) -> Set[str]: """Return a list of files to use, based on rules :param paths: the list pf paths to search (relative to self.path) @@ -164,6 +167,8 @@ class Generator: :param extensions: the list of allowed extensions (if False, all extensions are allowed) """ + if exclude is None: + exclude = [] # backward compatibility for older generators if isinstance(paths, str): paths = [paths] @@ -248,6 +253,13 @@ class Generator: # return the name of the class for logging purposes return self.__class__.__name__ + def _check_disabled_readers(self, paths, exclude: Optional[List[str]]) -> None: + """Log warnings for files that would have been processed by disabled readers.""" + for fil in self.get_files( + paths, exclude=exclude, extensions=self.readers.disabled_extensions + ): + self.readers.check_file(fil) + class CachingGenerator(Generator, FileStampDataCacher): """Subclass of Generator and FileStampDataCacher classes @@ -384,8 +396,8 @@ class ArticlesGenerator(CachingGenerator): str(self.settings["CATEGORY_FEED_ATOM"]).format(slug=cat.slug), self.settings.get( "CATEGORY_FEED_ATOM_URL", - str(self.settings["CATEGORY_FEED_ATOM"]).format(slug=cat.slug), - ), + str(self.settings["CATEGORY_FEED_ATOM"]), + ).format(slug=cat.slug), feed_title=cat.name, ) @@ -396,8 +408,8 @@ class ArticlesGenerator(CachingGenerator): str(self.settings["CATEGORY_FEED_RSS"]).format(slug=cat.slug), self.settings.get( "CATEGORY_FEED_RSS_URL", - str(self.settings["CATEGORY_FEED_RSS"]).format(slug=cat.slug), - ), + str(self.settings["CATEGORY_FEED_RSS"]), + ).format(slug=cat.slug), feed_title=cat.name, feed_type="rss", ) @@ -410,8 +422,8 @@ class ArticlesGenerator(CachingGenerator): str(self.settings["AUTHOR_FEED_ATOM"]).format(slug=auth.slug), self.settings.get( "AUTHOR_FEED_ATOM_URL", - str(self.settings["AUTHOR_FEED_ATOM"]).format(slug=auth.slug), - ), + str(self.settings["AUTHOR_FEED_ATOM"]), + ).format(slug=auth.slug), feed_title=auth.name, ) @@ -422,8 +434,8 @@ class ArticlesGenerator(CachingGenerator): str(self.settings["AUTHOR_FEED_RSS"]).format(slug=auth.slug), self.settings.get( "AUTHOR_FEED_RSS_URL", - str(self.settings["AUTHOR_FEED_RSS"]).format(slug=auth.slug), - ), + str(self.settings["AUTHOR_FEED_RSS"]), + ).format(slug=auth.slug), feed_title=auth.name, feed_type="rss", ) @@ -437,8 +449,8 @@ class ArticlesGenerator(CachingGenerator): str(self.settings["TAG_FEED_ATOM"]).format(slug=tag.slug), self.settings.get( "TAG_FEED_ATOM_URL", - str(self.settings["TAG_FEED_ATOM"]).format(slug=tag.slug), - ), + str(self.settings["TAG_FEED_ATOM"]), + ).format(slug=tag.slug), feed_title=tag.name, ) @@ -449,8 +461,8 @@ class ArticlesGenerator(CachingGenerator): str(self.settings["TAG_FEED_RSS"]).format(slug=tag.slug), self.settings.get( "TAG_FEED_RSS_URL", - str(self.settings["TAG_FEED_RSS"]).format(slug=tag.slug), - ), + str(self.settings["TAG_FEED_RSS"]), + ).format(slug=tag.slug), feed_title=tag.name, feed_type="rss", ) @@ -471,10 +483,8 @@ class ArticlesGenerator(CachingGenerator): str(self.settings["TRANSLATION_FEED_ATOM"]).format(lang=lang), self.settings.get( "TRANSLATION_FEED_ATOM_URL", - str(self.settings["TRANSLATION_FEED_ATOM"]).format( - lang=lang - ), - ), + str(self.settings["TRANSLATION_FEED_ATOM"]), + ).format(lang=lang), ) if self.settings.get("TRANSLATION_FEED_RSS"): writer.write_feed( @@ -537,9 +547,9 @@ class ArticlesGenerator(CachingGenerator): """Generate direct templates pages""" for template in self.settings["DIRECT_TEMPLATES"]: save_as = self.settings.get( - "%s_SAVE_AS" % template.upper(), "%s.html" % template + f"{template.upper()}_SAVE_AS", f"{template}.html" ) - url = self.settings.get("%s_URL" % template.upper(), "%s.html" % template) + url = self.settings.get(f"{template.upper()}_URL", f"{template}.html") if not save_as: continue @@ -643,6 +653,11 @@ class ArticlesGenerator(CachingGenerator): self.generate_authors(write) self.generate_drafts(write) + def check_disabled_readers(self) -> None: + self._check_disabled_readers( + self.settings["ARTICLE_PATHS"], exclude=self.settings["ARTICLE_EXCLUDES"] + ) + def generate_context(self): """Add the articles into the shared context""" @@ -849,6 +864,11 @@ class PagesGenerator(CachingGenerator): super().__init__(*args, **kwargs) signals.page_generator_init.send(self) + def check_disabled_readers(self) -> None: + self._check_disabled_readers( + self.settings["PAGE_PATHS"], exclude=self.settings["PAGE_EXCLUDES"] + ) + def generate_context(self): all_pages = [] hidden_pages = [] @@ -953,6 +973,11 @@ class StaticGenerator(Generator): self.fallback_to_symlinks = False signals.static_generator_init.send(self) + def check_disabled_readers(self) -> None: + self._check_disabled_readers( + self.settings["STATIC_PATHS"], exclude=self.settings["STATIC_EXCLUDES"] + ) + def generate_context(self): self.staticfiles = [] linked_files = set(self.context["static_links"]) @@ -1040,7 +1065,7 @@ class StaticGenerator(Generator): save_as = os.path.join(self.output_path, staticfile.save_as) s_mtime = os.path.getmtime(source_path) d_mtime = os.path.getmtime(save_as) - return s_mtime - d_mtime > 0.000001 + return s_mtime - d_mtime > 0.000001 # noqa: PLR2004 def _link_or_copy_staticfile(self, sc): if self.settings["STATIC_CREATE_LINKS"]: @@ -1070,7 +1095,7 @@ class StaticGenerator(Generator): except OSError as err: if err.errno == errno.EXDEV: # 18: Invalid cross-device link logger.debug( - "Cross-device links not valid. " "Creating symbolic links instead." + "Cross-device links not valid. Creating symbolic links instead." ) self.fallback_to_symlinks = True self._link_staticfile(sc) diff --git a/pelican/log.py b/pelican/log.py index 0d2b6a3f..edf2f182 100644 --- a/pelican/log.py +++ b/pelican/log.py @@ -85,13 +85,39 @@ class FatalLogger(LimitLogger): warnings_fatal = False errors_fatal = False - def warning(self, *args, **kwargs): - super().warning(*args, **kwargs) + def warning(self, *args, stacklevel=1, **kwargs): + """ + Displays a logging warning. + + Wrapping it here allows Pelican to filter warnings, and conditionally + make warnings fatal. + + Args: + stacklevel (int): the stacklevel that would be used to display the + calling location, except for this function. Adjusting the + stacklevel allows you to see the "true" calling location of the + warning, rather than this wrapper location. + """ + stacklevel += 1 + super().warning(*args, stacklevel=stacklevel, **kwargs) if FatalLogger.warnings_fatal: raise RuntimeError("Warning encountered") - def error(self, *args, **kwargs): - super().error(*args, **kwargs) + def error(self, *args, stacklevel=1, **kwargs): + """ + Displays a logging error. + + Wrapping it here allows Pelican to filter errors, and conditionally + make errors non-fatal. + + Args: + stacklevel (int): the stacklevel that would be used to display the + calling location, except for this function. Adjusting the + stacklevel allows you to see the "true" calling location of the + error, rather than this wrapper location. + """ + stacklevel += 1 + super().error(*args, stacklevel=stacklevel, **kwargs) if FatalLogger.errors_fatal: raise RuntimeError("Error encountered") @@ -100,11 +126,13 @@ logging.setLoggerClass(FatalLogger) # force root logger to be of our preferred class logging.getLogger().__class__ = FatalLogger +DEFAULT_LOG_HANDLER = RichHandler(console=console) + def init( level=None, fatal="", - handler=RichHandler(console=console), + handler=DEFAULT_LOG_HANDLER, name=None, logs_dedup_min_level=None, ): @@ -113,7 +141,10 @@ def init( LOG_FORMAT = "%(message)s" logging.basicConfig( - level=level, format=LOG_FORMAT, datefmt="[%H:%M:%S]", handlers=[handler] + level=level, + format=LOG_FORMAT, + datefmt="[%H:%M:%S]", + handlers=[handler] if handler else [], ) logger = logging.getLogger(name) diff --git a/pelican/paginator.py b/pelican/paginator.py index e1d50881..4a7c1aa2 100644 --- a/pelican/paginator.py +++ b/pelican/paginator.py @@ -5,7 +5,7 @@ from collections import namedtuple from math import ceil logger = logging.getLogger(__name__) -PaginationRule = namedtuple( +PaginationRule = namedtuple( # noqa: PYI024 "PaginationRule", "min_page URL SAVE_AS", ) @@ -131,9 +131,8 @@ class Page: if not self.has_next(): rule = p break - else: - if p.min_page <= self.number: - rule = p + elif p.min_page <= self.number: + rule = p if not rule: return "" diff --git a/pelican/plugins/_utils.py b/pelican/plugins/_utils.py index 805ed049..9dfc8f81 100644 --- a/pelican/plugins/_utils.py +++ b/pelican/plugins/_utils.py @@ -6,7 +6,6 @@ import logging import pkgutil import sys - logger = logging.getLogger(__name__) diff --git a/pelican/plugins/signals.py b/pelican/plugins/signals.py index 27177367..c36f595d 100644 --- a/pelican/plugins/signals.py +++ b/pelican/plugins/signals.py @@ -1,4 +1,4 @@ -from blinker import signal, Signal +from blinker import Signal, signal from ordered_set import OrderedSet # Signals will call functions in the order of connection, i.e. plugin order diff --git a/pelican/readers.py b/pelican/readers.py index 60b9765a..3d0e8d58 100644 --- a/pelican/readers.py +++ b/pelican/readers.py @@ -17,12 +17,12 @@ from pelican import rstdirectives # NOQA from pelican.cache import FileStampDataCacher from pelican.contents import Author, Category, Page, Tag from pelican.plugins import signals -from pelican.utils import get_date, pelican_open, posixize_path +from pelican.utils import file_suffix, get_date, pelican_open, posixize_path try: from markdown import Markdown except ImportError: - Markdown = False # NOQA + Markdown = False # Metadata processors have no way to discard an unwanted value, so we have # them return this value instead to signal that it should be discarded later. @@ -125,6 +125,10 @@ class BaseReader: metadata = {} return content, metadata + def disabled_message(self) -> str: + """Message about why this plugin was disabled.""" + return "" + class _FieldBodyTranslator(HTMLTranslator): def __init__(self, document): @@ -199,7 +203,7 @@ class RstReader(BaseReader): self._language_code = lang_code else: logger.warning( - "Docutils has no localization for '%s'." " Using 'en' instead.", + "Docutils has no localization for '%s'. Using 'en' instead.", lang_code, ) self._language_code = "en" @@ -320,7 +324,7 @@ class MarkdownReader(BaseReader): elif not DUPLICATES_DEFINITIONS_ALLOWED.get(name, True): if len(value) > 1: logger.warning( - "Duplicate definition of `%s` " "for %s. Using first one.", + "Duplicate definition of `%s` for %s. Using first one.", name, self._source_path, ) @@ -347,6 +351,12 @@ class MarkdownReader(BaseReader): metadata = {} return content, metadata + def disabled_message(self) -> str: + return ( + "Could not import 'markdown.Markdown'. " + "Have you installed the 'markdown' package?" + ) + class HTMLReader(BaseReader): """Parses HTML files as input, looking for meta, title, and body tags""" @@ -508,17 +518,23 @@ class Readers(FileStampDataCacher): def __init__(self, settings=None, cache_name=""): self.settings = settings or {} self.readers = {} + self.disabled_readers = {} + # extension => reader for readers that are enabled self.reader_classes = {} + # extension => reader for readers that are not enabled + disabled_reader_classes = {} for cls in [BaseReader] + BaseReader.__subclasses__(): if not cls.enabled: logger.debug( "Missing dependencies for %s", ", ".join(cls.file_extensions) ) - continue for ext in cls.file_extensions: - self.reader_classes[ext] = cls + if cls.enabled: + self.reader_classes[ext] = cls + else: + disabled_reader_classes[ext] = cls if self.settings["READERS"]: self.reader_classes.update(self.settings["READERS"]) @@ -531,6 +547,9 @@ class Readers(FileStampDataCacher): self.readers[fmt] = reader_class(self.settings) + for fmt, reader_class in disabled_reader_classes.items(): + self.disabled_readers[fmt] = reader_class(self.settings) + # set up caching cache_this_level = ( cache_name != "" and self.settings["CONTENT_CACHING_LAYER"] == "reader" @@ -541,8 +560,13 @@ class Readers(FileStampDataCacher): @property def extensions(self): + """File extensions that will be processed by a reader.""" return self.readers.keys() + @property + def disabled_extensions(self): + return self.disabled_readers.keys() + def read_file( self, base_path, @@ -562,8 +586,7 @@ class Readers(FileStampDataCacher): logger.debug("Read file %s -> %s", source_path, content_class.__name__) if not fmt: - _, ext = os.path.splitext(os.path.basename(path)) - fmt = ext[1:] + fmt = file_suffix(path) if fmt not in self.readers: raise TypeError("Pelican does not know how to parse %s", path) @@ -607,8 +630,8 @@ class Readers(FileStampDataCacher): # eventually filter the content with typogrify if asked so if self.settings["TYPOGRIFY"]: - from typogrify.filters import typogrify import smartypants + from typogrify.filters import typogrify typogrify_dashes = self.settings["TYPOGRIFY_DASHES"] if typogrify_dashes == "oldschool": @@ -654,6 +677,12 @@ class Readers(FileStampDataCacher): context=context, ) + def check_file(self, source_path: str) -> None: + """Log a warning if a file is processed by a disabled reader.""" + reader = self.disabled_readers.get(file_suffix(source_path), None) + if reader: + logger.warning(f"{source_path}: {reader.disabled_message()}") + def find_empty_alt(content, path): """Find images with empty alt diff --git a/pelican/rstdirectives.py b/pelican/rstdirectives.py index 0a549424..9022ac83 100644 --- a/pelican/rstdirectives.py +++ b/pelican/rstdirectives.py @@ -2,7 +2,6 @@ import re from docutils import nodes, utils from docutils.parsers.rst import Directive, directives, roles - from pygments import highlight from pygments.formatters import HtmlFormatter from pygments.lexers import TextLexer, get_lexer_by_name @@ -79,7 +78,7 @@ class abbreviation(nodes.Inline, nodes.TextElement): pass -def abbr_role(typ, rawtext, text, lineno, inliner, options={}, content=[]): +def abbr_role(typ, rawtext, text, lineno, inliner, options=None, content=None): text = utils.unescape(text) m = _abbr_re.search(text) if m is None: diff --git a/pelican/server.py b/pelican/server.py index 61729bf1..ebf13677 100644 --- a/pelican/server.py +++ b/pelican/server.py @@ -32,19 +32,18 @@ def parse_arguments(): "--cert", default="./cert.pem", nargs="?", - help="Path to certificate file. " + "Relative to current directory", + help="Path to certificate file. Relative to current directory", ) parser.add_argument( "--key", default="./key.pem", nargs="?", - help="Path to certificate key file. " + "Relative to current directory", + help="Path to certificate key file. Relative to current directory", ) parser.add_argument( "--path", default=".", - help="Path to pelican source directory to serve. " - + "Relative to current directory", + help="Path to pelican source directory to serve. Relative to current directory", ) return parser.parse_args() @@ -54,14 +53,12 @@ class ComplexHTTPRequestHandler(server.SimpleHTTPRequestHandler): extensions_map = { **server.SimpleHTTPRequestHandler.extensions_map, - **{ - # web fonts - ".oft": "font/oft", - ".sfnt": "font/sfnt", - ".ttf": "font/ttf", - ".woff": "font/woff", - ".woff2": "font/woff2", - }, + # web fonts + ".oft": "font/oft", + ".sfnt": "font/sfnt", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", } def translate_path(self, path): diff --git a/pelican/settings.py b/pelican/settings.py index 33ec210a..66d6beeb 100644 --- a/pelican/settings.py +++ b/pelican/settings.py @@ -8,11 +8,13 @@ import re import sys from os.path import isabs from pathlib import Path +from types import ModuleType +from typing import Any, Dict, Optional from pelican.log import LimitFilter -def load_source(name, path): +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 @@ -22,6 +24,8 @@ def load_source(name, path): logger = logging.getLogger(__name__) +Settings = Dict[str, Any] + DEFAULT_THEME = os.path.join( os.path.dirname(os.path.abspath(__file__)), "themes", "notmyidea" ) @@ -48,6 +52,7 @@ DEFAULT_CONFIG = { "TRANSLATION_FEED_ATOM": "feeds/all-{lang}.atom.xml", "FEED_MAX_ITEMS": 100, "RSS_FEED_SUMMARY_ONLY": True, + "FEED_APPEND_REF": False, "SITEURL": "", "SITENAME": "A Pelican Blog", "DISPLAY_PAGES_ON_MENU": True, @@ -177,7 +182,9 @@ DEFAULT_CONFIG = { PYGMENTS_RST_OPTIONS = None -def read_settings(path=None, override=None): +def read_settings( + path: Optional[str] = None, override: Optional[Settings] = None +) -> Settings: settings = override or {} if path: @@ -216,12 +223,12 @@ def read_settings(path=None, override=None): # parameters to docutils directive handlers, so we have to have a # variable here that we'll import from within Pygments.run (see # rstdirectives.py) to see what the user defaults were. - global PYGMENTS_RST_OPTIONS + global PYGMENTS_RST_OPTIONS # noqa: PLW0603 PYGMENTS_RST_OPTIONS = settings.get("PYGMENTS_RST_OPTIONS", None) return settings -def get_settings_from_module(module=None): +def get_settings_from_module(module: Optional[ModuleType] = None) -> Settings: """Loads settings from a module, returns a dictionary.""" context = {} @@ -230,7 +237,7 @@ def get_settings_from_module(module=None): return context -def get_settings_from_file(path): +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)) @@ -238,7 +245,7 @@ def get_settings_from_file(path): return get_settings_from_module(module) -def get_jinja_environment(settings): +def get_jinja_environment(settings: Settings) -> Settings: """Sets the environment for Jinja""" jinja_env = settings.setdefault( @@ -253,23 +260,21 @@ def get_jinja_environment(settings): return settings -def _printf_s_to_format_field(printf_string, format_field): +def _printf_s_to_format_field(printf_string: str, format_field: str) -> str: """Tries to replace %s with {format_field} in the provided printf_string. Raises ValueError in case of failure. """ TEST_STRING = "PELICAN_PRINTF_S_DEPRECATION" expected = printf_string % TEST_STRING - result = printf_string.replace("{", "{{").replace("}", "}}") % "{{{}}}".format( - format_field - ) + result = printf_string.replace("{", "{{").replace("}", "}}") % f"{{{format_field}}}" if result.format(**{format_field: TEST_STRING}) != expected: raise ValueError(f"Failed to safely replace %s with {{{format_field}}}") return result -def handle_deprecated_settings(settings): +def handle_deprecated_settings(settings: Settings) -> Settings: """Converts deprecated settings and issues warnings. Issues an exception if both old and new setting is specified. """ @@ -317,10 +322,7 @@ def handle_deprecated_settings(settings): "EXTRA_TEMPLATES_PATHS is deprecated use " "THEME_TEMPLATES_OVERRIDES instead." ) - if ( - "THEME_TEMPLATES_OVERRIDES" in settings - and settings["THEME_TEMPLATES_OVERRIDES"] - ): + if settings.get("THEME_TEMPLATES_OVERRIDES"): raise Exception( "Setting both EXTRA_TEMPLATES_PATHS and " "THEME_TEMPLATES_OVERRIDES is not permitted. Please move to " @@ -345,7 +347,7 @@ def handle_deprecated_settings(settings): "FILES_TO_COPY", "STATIC_PATHS and EXTRA_PATH_METADATA", "https://github.com/getpelican/pelican/" - "blob/master/docs/settings.rst#path-metadata", + "blob/main/docs/settings.rst#path-metadata", ), ]: if old in settings: @@ -405,7 +407,7 @@ def handle_deprecated_settings(settings): ) logger.warning(message) if old_values.get("SLUG"): - for f in {"CATEGORY", "TAG"}: + for f in ("CATEGORY", "TAG"): if old_values.get(f): old_values[f] = old_values["SLUG"] + old_values[f] old_values["AUTHOR"] = old_values.get("AUTHOR", []) @@ -445,7 +447,7 @@ def handle_deprecated_settings(settings): and not isinstance(settings[key], Path) and "%s" in settings[key] ): - logger.warning("%%s usage in %s is deprecated, use {lang} " "instead.", key) + logger.warning("%%s usage in %s is deprecated, use {lang} instead.", key) try: settings[key] = _printf_s_to_format_field(settings[key], "lang") except ValueError: @@ -468,7 +470,7 @@ def handle_deprecated_settings(settings): and not isinstance(settings[key], Path) and "%s" in settings[key] ): - logger.warning("%%s usage in %s is deprecated, use {slug} " "instead.", key) + logger.warning("%%s usage in %s is deprecated, use {slug} instead.", key) try: settings[key] = _printf_s_to_format_field(settings[key], "slug") except ValueError: @@ -566,7 +568,7 @@ def handle_deprecated_settings(settings): return settings -def configure_settings(settings): +def configure_settings(settings: Settings) -> Settings: """Provide optimizations, error checking, and warnings for the given settings. Also, specify the log messages to be ignored. @@ -589,7 +591,7 @@ def configure_settings(settings): if os.path.exists(theme_path): settings["THEME"] = theme_path else: - raise Exception("Could not find the theme %s" % settings["THEME"]) + raise Exception("Could not find the theme {}".format(settings["THEME"])) # standardize strings to lowercase strings for key in ["DEFAULT_LANG"]: @@ -612,7 +614,7 @@ def configure_settings(settings): if key in settings and not isinstance(settings[key], types): value = settings.pop(key) logger.warn( - "Detected misconfigured %s (%s), " "falling back to the default (%s)", + "Detected misconfigured %s (%s), falling back to the default (%s)", key, value, DEFAULT_CONFIG[key], @@ -674,7 +676,7 @@ def configure_settings(settings): if any(settings.get(k) for k in feed_keys): if not settings.get("SITEURL"): logger.warning( - "Feeds generated without SITEURL set properly may" " not be valid" + "Feeds generated without SITEURL set properly may not be valid" ) if "TIMEZONE" not in settings: diff --git a/pelican/tests/build_test/test_build_files.py b/pelican/tests/build_test/test_build_files.py index 9aad990d..c80253db 100644 --- a/pelican/tests/build_test/test_build_files.py +++ b/pelican/tests/build_test/test_build_files.py @@ -1,6 +1,6 @@ -from re import match import tarfile from pathlib import Path +from re import match from zipfile import ZipFile import pytest diff --git a/pelican/tests/content/article_with_inline_svg.html b/pelican/tests/content/article_with_inline_svg.html index 07f97a8a..06725704 100644 --- a/pelican/tests/content/article_with_inline_svg.html +++ b/pelican/tests/content/article_with_inline_svg.html @@ -5,13 +5,13 @@ Ensure that the title attribute in an inline svg is not handled as an HTML title. - - A different title inside the inline SVG - - - - - + + A different title inside the inline SVG + + + + + diff --git a/pelican/tests/content/medium_post_content.txt b/pelican/tests/content/medium_post_content.txt new file mode 100644 index 00000000..5e21881c --- /dev/null +++ b/pelican/tests/content/medium_post_content.txt @@ -0,0 +1,4 @@ + +

Title header

A paragraph of content.

Paragraph number two.

A list:

  1. One.
  2. Two.
  3. Three.

A link: link text.

Header 2

A block quote:

quote words strong words

after blockquote

A figure caption.

A final note: Cross-Validated has sometimes been helpful.


Next: Next post +

+

By User Name on .

Canonical link

Exported from Medium on December 1, 2023.

diff --git a/pelican/tests/content/medium_posts/2017-04-21_-medium-post--d1bf01d62ba3.html b/pelican/tests/content/medium_posts/2017-04-21_-medium-post--d1bf01d62ba3.html new file mode 100644 index 00000000..6d28f1a2 --- /dev/null +++ b/pelican/tests/content/medium_posts/2017-04-21_-medium-post--d1bf01d62ba3.html @@ -0,0 +1,72 @@ +A title diff --git a/pelican/tests/output/basic/a-markdown-powered-article.html b/pelican/tests/output/basic/a-markdown-powered-article.html index 0098ccac..66136d87 100644 --- a/pelican/tests/output/basic/a-markdown-powered-article.html +++ b/pelican/tests/output/basic/a-markdown-powered-article.html @@ -1,68 +1,68 @@ - - - - - A markdown powered article - - - - + + + + + A markdown powered article + + + + - - -
-
-
-

- A markdown powered article

-
+ + +
+ -
-
-
+
+
+ -
+ +
+ - - - \ No newline at end of file + + diff --git a/pelican/tests/output/basic/archives.html b/pelican/tests/output/basic/archives.html index e3a6c7df..7aa6b263 100644 --- a/pelican/tests/output/basic/archives.html +++ b/pelican/tests/output/basic/archives.html @@ -1,70 +1,70 @@ - - - - - A Pelican Blog - - - + + + + + A Pelican Blog + + + - - -
-

Archives for A Pelican Blog

+ + +
+

Archives for A Pelican Blog

-
-
Fri 30 November 2012
-
FILENAME_METADATA example
-
Wed 29 February 2012
-
Second article
-
Wed 20 April 2011
-
A markdown powered article
-
Thu 17 February 2011
-
Article 1
-
Thu 17 February 2011
-
Article 2
-
Thu 17 February 2011
-
Article 3
-
Thu 02 December 2010
-
This is a super article !
-
Wed 20 October 2010
-
Oh yeah !
-
Fri 15 October 2010
-
Unbelievable !
-
Sun 14 March 2010
-
The baz tag
-
-
-
-
+
+ -
+ +
+ - - - \ No newline at end of file + + diff --git a/pelican/tests/output/basic/article-1.html b/pelican/tests/output/basic/article-1.html index 961ad390..4ec2a0e1 100644 --- a/pelican/tests/output/basic/article-1.html +++ b/pelican/tests/output/basic/article-1.html @@ -1,67 +1,67 @@ - - - - - Article 1 - - - - + + + + + Article 1 + + + + - - -
-
-
-

- Article 1

-
+ + +
+
+
+

+ Article 1

+
-
-
- - Published: Thu 17 February 2011 - +
+
+ + Published: Thu 17 February 2011 + -

In cat1.

+

In cat1.

-

Article 1

+

Article 1

-
+ -
-
-
-
+
+
+
+

social

+ -
-
+ + + - - - \ No newline at end of file + + diff --git a/pelican/tests/output/basic/article-2.html b/pelican/tests/output/basic/article-2.html index e5389d35..99819902 100644 --- a/pelican/tests/output/basic/article-2.html +++ b/pelican/tests/output/basic/article-2.html @@ -1,67 +1,67 @@ - - - - - Article 2 - - - - + + + + + Article 2 + + + + - - -
-
-
-

- Article 2

-
+ + +
+
+
+

+ Article 2

+
-
-
- - Published: Thu 17 February 2011 - +
+
+ + Published: Thu 17 February 2011 + -

In cat1.

+

In cat1.

-

Article 2

+

Article 2

-
+ -
-
-
-
+
+
+
+

social

+ -
-
+ + + - - - \ No newline at end of file + + diff --git a/pelican/tests/output/basic/article-3.html b/pelican/tests/output/basic/article-3.html index d23e5da2..596db91f 100644 --- a/pelican/tests/output/basic/article-3.html +++ b/pelican/tests/output/basic/article-3.html @@ -1,67 +1,67 @@ - - - - - Article 3 - - - - + + + + + Article 3 + + + + - - -
-
-
-

- Article 3

-
+ + +
+
+
+

+ Article 3

+
-
-
- - Published: Thu 17 February 2011 - +
+
+ + Published: Thu 17 February 2011 + -

In cat1.

+

In cat1.

-

Article 3

+

Article 3

-
+ -
-
-
-
+
+
+
+

social

+ -
-
+ + + - - - \ No newline at end of file + + diff --git a/pelican/tests/output/basic/author/alexis-metaireau.html b/pelican/tests/output/basic/author/alexis-metaireau.html index 12e05ec8..e4bde41d 100644 --- a/pelican/tests/output/basic/author/alexis-metaireau.html +++ b/pelican/tests/output/basic/author/alexis-metaireau.html @@ -1,112 +1,112 @@ - - - - - A Pelican Blog - Alexis Métaireau - - - + + + + + A Pelican Blog - Alexis Métaireau + + + - - + + - +

→ And now try with some utf8 hell: ééé

+ + +
-

Other articles

-
-
    +

    Other articles

    +
    +
      -
    1. -
      -

      Oh yeah !

      -
      +
    2. +
      +

      Oh yeah !

      +
      -
      -
      - - Published: Wed 20 October 2010 - +
      +
      -

      Why not ?

      -

      After all, why not ? It's pretty simple to do it, and it will allow me to write my blogposts in rst ! -YEAH !

      -alternate text -
      +
      +

      Why not ?

      +

      After all, why not ? It's pretty simple to do it, and it will allow me to write my blogposts in rst ! + YEAH !

      + alternate text +
      - read more -
      -
    3. -
    + read more + + +
-
-
-

social

- +
+
- - - \ No newline at end of file + + diff --git a/pelican/tests/output/basic/authors.html b/pelican/tests/output/basic/authors.html index cff1360b..27d10c2f 100644 --- a/pelican/tests/output/basic/authors.html +++ b/pelican/tests/output/basic/authors.html @@ -1,52 +1,52 @@ - - - - - A Pelican Blog - Authors - - - + + + + + A Pelican Blog - Authors + + + - - + + -
-

Authors on A Pelican Blog

- -
- -
-
-

social

+
+

Authors on A Pelican Blog

-
-
+ - + + + - - \ No newline at end of file + + + + diff --git a/pelican/tests/output/basic/categories.html b/pelican/tests/output/basic/categories.html index cc54f4a3..5c85b20e 100644 --- a/pelican/tests/output/basic/categories.html +++ b/pelican/tests/output/basic/categories.html @@ -1,55 +1,55 @@ - - - - - A Pelican Blog - Categories - - - + + + + + A Pelican Blog - Categories + + + - - + + -
-

Categories for A Pelican Blog

- -
- -
-
-

social

+
+

Categories for A Pelican Blog

-
-
+ - + + + - - \ No newline at end of file + + + + diff --git a/pelican/tests/output/basic/category/bar.html b/pelican/tests/output/basic/category/bar.html index 1f9c0d8d..e89375bf 100644 --- a/pelican/tests/output/basic/category/bar.html +++ b/pelican/tests/output/basic/category/bar.html @@ -1,68 +1,68 @@ - - - - - A Pelican Blog - bar - - - + + + + + A Pelican Blog - bar + + + - - + + - +
+
+

social

+ -
-
+ + + - - - \ No newline at end of file + + diff --git a/pelican/tests/output/basic/category/cat1.html b/pelican/tests/output/basic/category/cat1.html index ca47821b..6c0cd64c 100644 --- a/pelican/tests/output/basic/category/cat1.html +++ b/pelican/tests/output/basic/category/cat1.html @@ -1,125 +1,125 @@ - - - - - A Pelican Blog - cat1 - - - + + + + + A Pelican Blog - cat1 + + + - - + + -
-

Other articles

-
-
    +

    Other articles

    +
    +
      -
    1. -
    2. -
    3. -
      -

      Article 3

      -
      +
    4. +
      +

      Article 3

      +
      -
      -
      - - Published: Thu 17 February 2011 - +
      +
      + + Published: Thu 17 February 2011 + -

      In cat1.

      +

      In cat1.

      -

      Article 3

      +

      Article 3

      - read more -
      -
    5. -
    + read more + + +
-
-
-

social

- +
+
- - - \ No newline at end of file + + diff --git a/pelican/tests/output/basic/category/misc.html b/pelican/tests/output/basic/category/misc.html index 58490001..fa9eb563 100644 --- a/pelican/tests/output/basic/category/misc.html +++ b/pelican/tests/output/basic/category/misc.html @@ -1,136 +1,136 @@ - - - - - A Pelican Blog - misc - - - + + + + + A Pelican Blog - misc + + + - - + + -
-

Other articles

-
-
    +

    Other articles

    +
    +
      -
    1. -
    2. -
      -

      Unbelievable !

      -
      +
    3. +
      +

      Unbelievable !

      +
      -
      -