diff --git a/.coveragerc b/.coveragerc index 2cb24879..fdd2cad6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,2 @@ [report] omit = pelican/tests/* - diff --git a/.editorconfig b/.editorconfig index edb13c8a..a9c06c97 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ insert_final_newline = true trim_trailing_whitespace = true [*.py] -max_line_length = 79 +max_line_length = 88 [*.{yml,yaml}] indent_size = 2 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..0d92c9d9 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,7 @@ +# .git-blame-ignore-revs +# Apply code style to project via: ruff format . +cabdb26cee66e1173cf16cb31d3fe5f9fa4392e7 +# Upgrade code base for Python 3.8 and above +ecd598f293161a52564aa6e8dfdcc8284dc93970 +# Apply Ruff and pyupgrade to Jinja templates +db241feaa445375dc05e189e69287000ffe5fa8e diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d080329a..9e240bd9 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,8 @@ +--- # Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser blank_issues_enabled: true contact_links: -- name: '💬 Pelican IRC Channel on Freenode' - url: https://kiwiirc.com/client/irc.freenode.net/?#pelican +- name: '💬 Pelican IRC Channel' + url: https://web.libera.chat/?#pelican about: | Chat with the community, ask questions, and learn about best practices. diff --git a/.github/workflows/github_pages.yml b/.github/workflows/github_pages.yml new file mode 100644 index 00000000..7eae170a --- /dev/null +++ b/.github/workflows/github_pages.yml @@ -0,0 +1,65 @@ +name: Deploy to GitHub Pages +on: + workflow_call: + inputs: + settings: + required: true + description: "The path to your Pelican settings file (`pelican`'s `--settings` option), for example: 'publishconf.py'" + type: string + requirements: + required: false + default: "pelican" + description: "The Python requirements to install, for example to enable markdown and typogrify use: 'pelican[markdown] typogrify' or if you have a requirements file use: '-r requirements.txt'" + type: string + output-path: + required: false + default: "output/" + description: "Where to output the generated files (`pelican`'s `--output` option)" + type: string +permissions: + contents: read + pages: write + id-token: write +concurrency: + group: "pages" + cancel-in-progress: false +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Configure GitHub Pages + id: pages + uses: actions/configure-pages@v3 + - name: Install requirements + run: pip install ${{ inputs.requirements }} + - name: Build Pelican site + run: | + pelican \ + --settings "${{ inputs.settings }}" \ + --extra-settings SITEURL='"${{ steps.pages.outputs.base_url }}"' \ + --output "${{ inputs.output-path }}" + - 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 + with: + path: ${{ inputs.output-path }} + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 58333075..c29a08c2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,35 +9,25 @@ env: jobs: test: - name: Test - ${{ matrix.config.python }} - ${{ matrix.config.os }} - runs-on: ${{ matrix.config.os }}-latest + name: Test - ${{ matrix.python }} - ${{ matrix.os }} + runs-on: ${{ matrix.os }}-latest strategy: matrix: - config: - - os: ubuntu - python: "3.7" + os: [ubuntu, macos, windows] + python: ["3.10", "3.11", "3.12"] + include: - os: ubuntu python: "3.8" - os: ubuntu python: "3.9" - - os: ubuntu - python: "3.10" - - os: ubuntu - python: "3.11" - - os: ubuntu - python: "3.12" - - os: macos - python: "3.7" - - os: windows - python: "3.7" steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.config.python }} + - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v4 with: - python-version: ${{ matrix.config.python }} + python-version: ${{ matrix.python }} cache: "pip" cache-dependency-path: "**/requirements/*" - name: Install locale (Linux) @@ -56,24 +46,42 @@ jobs: echo "===== PANDOC =====" pandoc --version | head -2 - name: Run tests - run: tox -e py${{ matrix.config.python }} + run: tox -e py${{ matrix.python }} lint: name: Lint runs-on: ubuntu-latest - steps: - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 + - uses: pdm-project/setup-pdm@v3 with: - python-version: "3.9" - cache: "pip" - cache-dependency-path: "**/requirements/*" - - name: Install tox - run: python -m pip install -U pip tox - - name: Check - run: tox -e flake8 + python-version: 3.9 + cache: true + cache-dependency-path: ./pyproject.toml + - name: Install dependencies + run: | + pdm install --no-default --dev + - name: Run linters + run: pdm lint --diff + - name: Run pre-commit checks on all files + uses: pre-commit/action@v3.0.0 + + build: + name: Test build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: pdm-project/setup-pdm@v3 + with: + python-version: 3.9 + cache: true + cache-dependency-path: ./pyproject.toml + - name: Install dependencies + run: pdm install --dev + - name: Build package + run: pdm build + - name: Test build + run: pdm run pytest --check-build=dist pelican/tests/build_test docs: name: Build docs @@ -91,13 +99,18 @@ jobs: run: python -m pip install -U pip tox - name: Check run: tox -e docs + - name: cache the docs for inspection + uses: actions/upload-artifact@v3 + with: + name: docs + path: docs/_build/html/ deploy: name: Deploy environment: Deployment - needs: [test, lint, docs] + needs: [test, lint, docs, build] runs-on: ubuntu-latest - if: github.ref=='refs/heads/master' && github.event_name!='pull_request' + if: github.ref=='refs/heads/master' && github.event_name!='pull_request' && github.repository == 'getpelican/pelican' permissions: contents: write diff --git a/.gitignore b/.gitignore index b94526d6..473efea2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ htmlcov venv samples/output *.pem -poetry.lock +*.lock +.pdm-python +.venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c0c85b0..333bc3c0 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.4.0 + rev: v4.5.0 hooks: - id: check-added-large-files - id: check-ast @@ -13,13 +13,11 @@ repos: - id: end-of-file-fixer - id: forbid-new-submodules - id: trailing-whitespace - - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.5 hooks: - - id: flake8 - name: Flake8 on commit diff - description: This hook limits Flake8 checks to changed lines of code. - entry: bash - args: [-c, 'git diff HEAD | flake8 --diff --max-line-length=88'] + - id: ruff + - id: ruff-format + args: ["--check"] exclude: ^pelican/tests/output/ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c1175aa4..4faace91 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -79,6 +79,10 @@ don't spend a lot of time working on something that would be rejected for a known reason. Consider also whether your new feature might be better suited as a ':pelican-doc:`plugins` — you can `ask for help`_ to make that determination. +Also, if you intend to submit a pull request to address something for which there +is no existing issue, there is no need to create a new issue and then immediately +submit a pull request that closes it. You can submit the pull request by itself. + Using Git and GitHub -------------------- @@ -87,7 +91,8 @@ Using Git and GitHub * **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 - request** for the bugfix. + request** for the bugfix. Similarly, any proposed changes to code style + formatting should be in a completely separate pull request. * Add a ``RELEASE.md`` file in the root of the project that contains the release type (major, minor, patch) and a summary of the changes that will be used as the release changelog entry. For example:: @@ -106,15 +111,8 @@ Using Git and GitHub detailed explanation (when relevant). * `Squash your commits`_ to eliminate merge commits and ensure a clean and readable commit history. -* If you have previously filed a GitHub issue and want to contribute code that - addresses that issue, **please use** ``hub pull-request`` instead of using - GitHub's web UI to submit the pull request. This isn't an absolute - requirement, but makes the maintainers' lives much easier! Specifically: - `install hub `_ and then run - `hub pull-request -i [ISSUE] `_ - to turn your GitHub issue into a pull request containing your code. * After you have issued a pull request, the continuous integration (CI) system - will run the test suite for all supported Python versions and check for PEP8 + will run the test suite on all supported Python versions and check for code style compliance. If any of these checks fail, you should fix them. (If tests fail on the CI system but seem to pass locally, ensure that local test runs aren't skipping any tests.) @@ -122,13 +120,7 @@ Using Git and GitHub Contribution quality standards ------------------------------ -* Adhere to `PEP8 coding standards`_. This can be eased via the `pycodestyle - `_ or `flake8 - `_ tools, the latter of which in - particular will give you some useful hints about ways in which the - code/formatting can be improved. We try to keep line length within the - 79-character maximum specified by PEP8. Because that can sometimes compromise - readability, the hard/enforced maximum is 88 characters. +* Adhere to the project's code style standards. See: `Development Environment`_ * Ensure your code is compatible with the `officially-supported Python releases`_. * Add docs and tests for your changes. Undocumented and untested features will not be accepted. @@ -142,6 +134,6 @@ need assistance or have any questions about these guidelines. .. _`Create a new branch`: https://github.com/getpelican/pelican/wiki/Git-Tips#making-your-changes .. _`Squash your commits`: https://github.com/getpelican/pelican/wiki/Git-Tips#squashing-commits .. _`Git Tips`: https://github.com/getpelican/pelican/wiki/Git-Tips -.. _`PEP8 coding standards`: https://www.python.org/dev/peps/pep-0008/ .. _`ask for help`: `How to get help`_ -.. _`officially-supported Python releases`: https://devguide.python.org/#status-of-python-branches +.. _`Development Environment`: https://docs.getpelican.com/en/latest/contribute.html#setting-up-the-development-environment +.. _`officially-supported Python releases`: https://devguide.python.org/versions/#versions diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 87d433a8..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include *.rst -recursive-include pelican *.html *.css *png *.rst *.markdown *.md *.mkd *.xml *.py *.jinja2 -include LICENSE THANKS docs/changelog.rst pyproject.toml -graft samples -global-exclude __pycache__ -global-exclude *.py[co] \ No newline at end of file diff --git a/docs/_static/pelican-logo.svg b/docs/_static/pelican-logo.svg index 95b947bf..f36b42fa 100644 --- a/docs/_static/pelican-logo.svg +++ b/docs/_static/pelican-logo.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/docs/_static/theme_overrides.css b/docs/_static/theme_overrides.css index 83afc78e..e840ab6b 100644 --- a/docs/_static/theme_overrides.css +++ b/docs/_static/theme_overrides.css @@ -9,4 +9,3 @@ .wy-table-responsive { overflow: visible !important; } - diff --git a/docs/changelog.rst b/docs/changelog.rst index 88353ed4..5ef19b17 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,34 @@ Release history ############### +4.9.1 - 2023-11-15 +================== + +* Ensure ``tzdata`` dependency is installed on Windows + +4.9.0 - 2023-11-12 +================== + +* Upgrade code to new minimum supported Python version: 3.8 +* Settings support for ``pathlib.Path`` `(#2758) `_ +* Various improvements to Simple theme (`#2976 `_ & `#3234 `_) +* Use Furo as Sphinx documentation theme `(#3023) `_ +* Default to 100 articles maximum in feeds `(#3127) `_ +* Add ``period_archives common context`` variable `(#3148) `_ +* Use ``watchfiles`` as the file-watching backend `(#3151) `_ +* Add GitHub Actions workflow for GitHub Pages `(#3189) `_ +* Allow dataclasses in settings `(#3204) `_ +* Switch build tool to PDM instead of Setuptools/Poetry `(#3220) `_ +* Provide a ``plugin_enabled`` Jinja test for themes `(#3235) `_ +* Preserve connection order in Blinker `(#3238) `_ +* Remove social icons from default ``notmyidea`` theme `(#3240) `_ +* Remove unreliable ``WRITE_SELECTED`` feature `(#3243) `_ +* Importer: Report broken embedded video links when importing from Tumblr `(#3177) `_ +* Importer: Remove newline addition when iterating Photo post types `(#3178) `_ +* Importer: Force timestamp conversion in Tumblr importer to be UTC with offset `(#3221) `_ +* Importer: Use tempfile for intermediate HTML file for Pandoc `(#3221) `_ +* Switch linters to Ruff `(#3223) `_ + 4.8.0 - 2022-07-11 ================== diff --git a/docs/conf.py b/docs/conf.py index 8f80ba63..8d8078a2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,45 +2,58 @@ import datetime import os import sys -from pelican import __version__ +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + sys.path.append(os.path.abspath(os.pardir)) -# -- General configuration ---------------------------------------------------- -templates_path = ['_templates'] -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.ifconfig', - 'sphinx.ext.extlinks'] -source_suffix = '.rst' -master_doc = 'index' -project = 'Pelican' -year = datetime.datetime.now().date().year -copyright = f'2010–{year}' -exclude_patterns = ['_build'] -release = __version__ -version = '.'.join(release.split('.')[:1]) -last_stable = __version__ -rst_prolog = ''' -.. |last_stable| replace:: :pelican-doc:`{}` -'''.format(last_stable) -extlinks = { - 'pelican-doc': ('https://docs.getpelican.com/en/latest/%s.html', '%s') -} +with open("../pyproject.toml", "rb") as f: + project_data = tomllib.load(f).get("project") + if project_data is None: + raise KeyError("project data is not found") + + +# -- General configuration ---------------------------------------------------- +templates_path = ["_templates"] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.ifconfig", + "sphinx.ext.extlinks", + "sphinxext.opengraph", +] +source_suffix = ".rst" +master_doc = "index" +project = project_data.get("name").upper() +year = datetime.datetime.now().date().year +copyright = f"2010–{year}" +exclude_patterns = ["_build"] +release = project_data.get("version") +version = ".".join(release.split(".")[:1]) +last_stable = project_data.get("version") +rst_prolog = f""" +.. |last_stable| replace:: :pelican-doc:`{last_stable}` +.. |min_python| replace:: {project_data.get('requires-python').split(",")[0]} +""" + +extlinks = {"pelican-doc": ("https://docs.getpelican.com/en/latest/%s.html", "%s")} # -- Options for HTML output -------------------------------------------------- -html_theme = 'furo' -html_title = f'{project} {release}' -html_static_path = ['_static'] +html_theme = "furo" +html_title = f"{project} {release}" +html_static_path = ["_static"] html_theme_options = { - 'light_logo': 'pelican-logo.svg', - 'dark_logo': 'pelican-logo.svg', - 'navigation_with_keys': True, + "light_logo": "pelican-logo.svg", + "dark_logo": "pelican-logo.svg", + "navigation_with_keys": True, } # Output file base name for HTML help builder. -htmlhelp_basename = 'Pelicandoc' +htmlhelp_basename = "Pelicandoc" html_use_smartypants = True @@ -56,21 +69,29 @@ html_show_sourcelink = False def setup(app): # overrides for wide tables in RTD theme - app.add_css_file('theme_overrides.css') # path relative to _static + app.add_css_file("theme_overrides.css") # path relative to _static # -- Options for LaTeX output ------------------------------------------------- latex_documents = [ - ('index', 'Pelican.tex', 'Pelican Documentation', 'Justin Mayer', - 'manual'), + ("index", "Pelican.tex", "Pelican Documentation", "Justin Mayer", "manual"), ] # -- Options for manual page output ------------------------------------------- man_pages = [ - ('index', 'pelican', 'pelican documentation', - ['Justin Mayer'], 1), - ('pelican-themes', 'pelican-themes', 'A theme manager for Pelican', - ['Mickaël Raybaud'], 1), - ('themes', 'pelican-theming', 'How to create themes for Pelican', - ['The Pelican contributors'], 1) + ("index", "pelican", "pelican documentation", ["Justin Mayer"], 1), + ( + "pelican-themes", + "pelican-themes", + "A theme manager for Pelican", + ["Mickaël Raybaud"], + 1, + ), + ( + "themes", + "pelican-theming", + "How to create themes for Pelican", + ["The Pelican contributors"], + 1, + ), ] diff --git a/docs/contribute.rst b/docs/contribute.rst index 64c144d6..6a5a417e 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -15,16 +15,16 @@ Setting up the development environment ====================================== While there are many ways to set up one's development environment, the following -instructions will utilize Pip_ and Poetry_. These tools facilitate managing +instructions will utilize Pip_ and PDM_. These tools facilitate managing virtual environments for separate Python projects that are isolated from one another, so you can use different packages (and package versions) for each. -Please note that Python 3.7+ is required for Pelican development. +Please note that Python |min_python| is required for Pelican development. -*(Optional)* If you prefer to `install Poetry `_ once for use with multiple projects, +*(Optional)* If you prefer to `install PDM `_ once for use with multiple projects, you can install it via:: - curl -sSL https://install.python-poetry.org | python3 - + curl -sSL https://pdm.fming.dev/install-pdm.py | python3 - Point your web browser to the `Pelican repository`_ and tap the **Fork** button at top-right. Then clone the source for your fork and add the upstream project @@ -35,7 +35,7 @@ as a Git remote:: cd ~/projects/pelican git remote add upstream https://github.com/getpelican/pelican.git -While Poetry can dynamically create and manage virtual environments, we're going +While PDM can dynamically create and manage virtual environments, we're going to manually create and activate a virtual environment:: mkdir ~/virtualenvs && cd ~/virtualenvs @@ -46,12 +46,11 @@ Install the needed dependencies and set up the project:: python -m pip install invoke invoke setup - python -m pip install -e ~/projects/pelican Your local environment should now be ready to go! .. _Pip: https://pip.pypa.io/ -.. _Poetry: https://python-poetry.org/ +.. _PDM: https://pdm.fming.dev/latest/ .. _Pelican repository: https://github.com/getpelican/pelican Development @@ -75,6 +74,9 @@ via:: invoke tests +(For more on Invoke, see ``invoke -l`` to list tasks, or +https://pyinvoke.org for documentation.) + In addition to running the test suite, it is important to also ensure that any lines you changed conform to code style guidelines. You can check that via:: @@ -156,8 +158,7 @@ check for code style compliance via:: If style violations are found, many of them can be addressed automatically via:: - invoke black - invoke isort + invoke format If style violations are found even after running the above auto-formatters, you will need to make additional manual changes until ``invoke lint`` no longer diff --git a/docs/faq.rst b/docs/faq.rst index c065b4ed..cecc1157 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -217,10 +217,6 @@ changed. A simple solution is to make ``rsync`` use the ``--checksum`` option, which will make it compare the file checksums in a much faster way than Pelican would. -When only several specific output files are of interest (e.g. when working on -some specific page or the theme templates), the ``WRITE_SELECTED`` option may -help, see :ref:`writing_only_selected_content`. - How to process only a subset of all articles? ============================================= diff --git a/docs/importer.rst b/docs/importer.rst index 7b839d30..997a4632 100644 --- a/docs/importer.rst +++ b/docs/importer.rst @@ -11,7 +11,6 @@ software to reStructuredText or Markdown. The supported import formats are: - Blogger XML export - Dotclear export -- Posterous API - Tumblr API - WordPress XML export - RSS/Atom feed @@ -48,16 +47,15 @@ Usage :: - pelican-import [-h] [--blogger] [--dotclear] [--posterous] [--tumblr] [--wpfile] [--feed] + pelican-import [-h] [--blogger] [--dotclear] [--tumblr] [--wpfile] [--feed] [-o OUTPUT] [-m MARKUP] [--dir-cat] [--dir-page] [--strip-raw] [--wp-custpost] - [--wp-attach] [--disable-slugs] [-e EMAIL] [-p PASSWORD] [-b BLOGNAME] - input|api_token|api_key + [--wp-attach] [--disable-slugs] [-b BLOGNAME] + input|api_key Positional arguments -------------------- ============= ============================================================================ ``input`` The input file to read - ``api_token`` (Posterous only) api_token can be obtained from http://posterous.com/api/ ``api_key`` (Tumblr only) api_key can be obtained from https://www.tumblr.com/oauth/apps ============= ============================================================================ @@ -67,7 +65,6 @@ Optional arguments -h, --help Show this help message and exit --blogger Blogger XML export (default: False) --dotclear Dotclear export (default: False) - --posterous Posterous API (default: False) --tumblr Tumblr API (default: False) --wpfile WordPress XML export (default: False) --feed Feed to parse (default: False) @@ -101,10 +98,6 @@ Optional arguments output. With this disabled, your Pelican URLs may not be consistent with your original posts. (default: False) - -e EMAIL, --email=EMAIL - Email used to authenticate Posterous API - -p PASSWORD, --password=PASSWORD - Password used to authenticate Posterous API -b BLOGNAME, --blogname=BLOGNAME Blog name used in Tumblr API @@ -120,13 +113,9 @@ For Dotclear:: $ pelican-import --dotclear -o ~/output ~/backup.txt -for Posterous:: - - $ pelican-import --posterous -o ~/output --email= --password= - For Tumblr:: - $ pelican-import --tumblr -o ~/output --blogname= + $ pelican-import --tumblr -o ~/output --blogname= For WordPress:: diff --git a/docs/install.rst b/docs/install.rst index ea47311f..aa3c92d0 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -1,7 +1,7 @@ Installing Pelican ################## -Pelican currently runs best on 3.7+; earlier versions of Python are not supported. +Pelican currently runs best on |min_python|; earlier versions of Python are not supported. You can install Pelican via several different methods. The simplest is via Pip_:: diff --git a/docs/plugins.rst b/docs/plugins.rst index db7e00b4..22e1bcc6 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -94,9 +94,12 @@ which you map the signals to your plugin logic. Let's take a simple example:: your ``register`` callable or they will be garbage-collected before the signal is emitted. -If multiple plugins connect to the same signal, there is no way to guarantee or -control in which order the plugins will be executed. This is a limitation -inherited from Blinker_, the dependency Pelican uses to implement signals. +If multiple plugins connect to the same signal, plugins will be executed in the +order they are connected. With ``PLUGINS`` setting, order will be as defined in +the setting. If you rely on auto-discovered namespace plugins, no ``PLUGINS`` +setting, they will be connected in the same order they are discovered (same +order as ``pelican-plugins`` output). If you want to specify the order +explicitly, disable auto-discovery by defining ``PLUGINS`` in the desired order. Namespace plugin structure -------------------------- @@ -341,4 +344,3 @@ custom article, using the ``article_generator_pretaxonomy`` signal:: .. _Pip: https://pip.pypa.io/ .. _pelican-plugins bug #314: https://github.com/getpelican/pelican-plugins/issues/314 -.. _Blinker: https://pythonhosted.org/blinker/ diff --git a/docs/publish.rst b/docs/publish.rst index f5ebfff5..e687b65a 100644 --- a/docs/publish.rst +++ b/docs/publish.rst @@ -18,18 +18,6 @@ folder, using the default theme to produce a simple site. The default theme consists of very simple HTML without styling and is provided so folks may use it as a basis for creating their own themes. -When working on a single article or page, it is possible to generate only the -file that corresponds to that content. To do this, use the ``--write-selected`` -argument, like so:: - - pelican --write-selected output/posts/my-post-title.html - -Note that you must specify the path to the generated *output* file — not the -source content. To determine the output file name and location, use the -``--debug`` flag. If desired, ``--write-selected`` can take a comma-separated -list of paths or can be configured as a setting. (See: -:ref:`writing_only_selected_content`) - You can also tell Pelican to watch for your modifications, instead of manually re-running it every time you want to see your changes. To enable this, run the ``pelican`` command with the ``-r`` or ``--autoreload`` option. On non-Windows diff --git a/docs/quickstart.rst b/docs/quickstart.rst index f1198b94..686b822f 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -8,7 +8,7 @@ Installation ------------ Install Pelican (and optionally Markdown if you intend to use it) on Python -3.7+ by running the following command in your preferred terminal, prefixing +|min_python| by running the following command in your preferred terminal, prefixing with ``sudo`` if permissions warrant:: python -m pip install "pelican[markdown]" diff --git a/docs/settings.rst b/docs/settings.rst index 8417e209..e9edffde 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -362,13 +362,6 @@ Basic settings If ``True``, load unmodified content from caches. -.. data:: WRITE_SELECTED = [] - - If this list is not empty, **only** output files with their paths in this - list are written. Paths should be either absolute or relative to the current - Pelican working directory. For possible use cases see - :ref:`writing_only_selected_content`. - .. data:: FORMATTED_FIELDS = ['summary'] A list of metadata fields containing reST/Markdown content to be parsed and @@ -564,44 +557,47 @@ written over time. Example usage:: YEAR_ARCHIVE_SAVE_AS = 'posts/{date:%Y}/index.html' + YEAR_ARCHIVE_URL = 'posts/{date:%Y}/' MONTH_ARCHIVE_SAVE_AS = 'posts/{date:%Y}/{date:%b}/index.html' + MONTH_ARCHIVE_URL = 'posts/{date:%Y}/{date:%b}/' With these settings, Pelican will create an archive of all your posts for the year at (for instance) ``posts/2011/index.html`` and an archive of all your -posts for the month at ``posts/2011/Aug/index.html``. +posts for the month at ``posts/2011/Aug/index.html``. These can be accessed +through the URLs ``posts/2011/`` and ``posts/2011/Aug/``, respectively. .. note:: Period archives work best when the final path segment is ``index.html``. This way a reader can remove a portion of your URL and automatically arrive at an appropriate archive of posts, without having to specify a page name. -.. data:: YEAR_ARCHIVE_URL = '' - - The URL to use for per-year archives of your posts. Used only if you have - the ``{url}`` placeholder in ``PAGINATION_PATTERNS``. - .. data:: YEAR_ARCHIVE_SAVE_AS = '' The location to save per-year archives of your posts. -.. data:: MONTH_ARCHIVE_URL = '' +.. data:: YEAR_ARCHIVE_URL = '' - The URL to use for per-month archives of your posts. Used only if you have - the ``{url}`` placeholder in ``PAGINATION_PATTERNS``. + The URL to use for per-year archives of your posts. You should set this if + you enable per-year archives. .. data:: MONTH_ARCHIVE_SAVE_AS = '' The location to save per-month archives of your posts. -.. data:: DAY_ARCHIVE_URL = '' +.. data:: MONTH_ARCHIVE_URL = '' - The URL to use for per-day archives of your posts. Used only if you have the - ``{url}`` placeholder in ``PAGINATION_PATTERNS``. + The URL to use for per-month archives of your posts. You should set this if + you enable per-month archives. .. data:: DAY_ARCHIVE_SAVE_AS = '' The location to save per-day archives of your posts. +.. data:: DAY_ARCHIVE_URL = '' + + The URL to use for per-day archives of your posts. You should set this if + you enable per-day archives. + ``DIRECT_TEMPLATES`` work a bit differently than noted above. Only the ``_SAVE_AS`` settings are available, but it is available for any direct template. @@ -1001,10 +997,10 @@ the ``TAG_FEED_ATOM`` and ``TAG_FEED_RSS`` settings: placeholder. If not set, ``TAG_FEED_RSS`` is used both for save location and URL. -.. data:: FEED_MAX_ITEMS +.. data:: FEED_MAX_ITEMS = 100 - Maximum number of items allowed in a feed. Feed item quantity is - unrestricted by default. + Maximum number of items allowed in a feed. Setting to ``None`` will cause the + feed to contains every article. 100 if not specified. .. data:: RSS_FEED_SUMMARY_ONLY = True @@ -1228,6 +1224,12 @@ Following are example ways to specify your preferred theme:: # Specify a customized theme, via absolute path THEME = "/home/myuser/projects/mysite/themes/mycustomtheme" +The built-in ``simple`` theme can be customized using the following settings. + +.. data:: STYLESHEET_URL + + The URL of the stylesheet to use. + The built-in ``notmyidea`` theme can make good use of the following settings. Feel free to use them in your themes as well. @@ -1391,21 +1393,6 @@ modification times of the generated ``*.html`` files will always change. Therefore, ``rsync``-based uploading may benefit from the ``--checksum`` option. -.. _writing_only_selected_content: - - -Writing only selected content -============================= - -When only working on a single article or page, or making tweaks to your theme, -it is often desirable to generate and review your work as quickly as possible. -In such cases, generating and writing the entire site output is often -unnecessary. By specifying only the desired files as output paths in the -``WRITE_SELECTED`` list, **only** those files will be written. This list can be -also specified on the command line using the ``--write-selected`` option, which -accepts a comma-separated list of output file paths. By default this list is -empty, so all output is written. See :ref:`site_generation` for more details. - Example settings ================ diff --git a/docs/themes.rst b/docs/themes.rst index db307878..2e01ec8e 100644 --- a/docs/themes.rst +++ b/docs/themes.rst @@ -71,6 +71,8 @@ All templates will receive the variables defined in your settings file, as long as they are in all-caps. You can access them directly. +.. _common_variables: + Common Variables ---------------- @@ -92,6 +94,10 @@ dates The same list of articles, but ordered by date, ascending. hidden_articles The list of hidden articles drafts The list of draft articles +period_archives A dictionary containing elements related to + time-period archives (if enabled). See the section + :ref:`Listing and Linking to Period Archives + ` for details. authors A list of (author, articles) tuples, containing all the authors and corresponding articles (values) categories A list of (category, articles) tuples, containing @@ -134,6 +140,23 @@ your date according to the locale given in your settings:: .. _datetime: https://docs.python.org/3/library/datetime.html#datetime-objects .. _strftime: https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior +Checking Loaded Plugins +----------------------- + +Pelican provides a ``plugin_enabled`` Jinja test for checking if a certain plugin +is enabled. This test accepts a plugin name as a string and will return a Boolean. +Namespace plugins can be specified by full name (``pelican.plugins.plugin_name``) +or short name (``plugin_name``). The following example uses the ``webassets`` plugin +to minify CSS if the plugin is enabled and otherwise falls back to regular CSS:: + + {% if "webassets" is plugin_enabled %} + {% assets filters="cssmin", output="css/style.min.css", "css/style.scss" %} + + {% endassets %} + {% else %} + + {% endif %} + index.html ---------- @@ -348,6 +371,63 @@ period_archives.html template `_. +.. _period_archives_variable: + +Listing and Linking to Period Archives +"""""""""""""""""""""""""""""""""""""" + +The ``period_archives`` variable can be used to generate a list of links to +the set of period archives that Pelican generates. As a :ref:`common variable +`, it is available for use in any template, so you +can implement such an index in a custom direct template, or in a sidebar +visible across different site pages. + +``period_archives`` is a dict that may contain ``year``, ``month``, and/or +``day`` keys, depending on which ``*_ARCHIVE_SAVE_AS`` settings are enabled. +The corresponding value is a list of dicts, where each dict in turn represents +a time period (ordered according to the ``NEWEST_FIRST_ARCHIVES`` setting) +with the following keys and values: + +=================== =================================================== +Key Value +=================== =================================================== +period The same tuple as described in + ``period_archives.html``, e.g. + ``(2023, 'June', 18)``. +period_num The same tuple as described in + ``period_archives.html``, e.g. ``(2023, 6, 18)``. +url The URL to the period archive page, e.g. + ``posts/2023/06/18/``. This is controlled by the + corresponding ``*_ARCHIVE_URL`` setting. +save_as The path to the save location of the period archive + page file, e.g. ``posts/2023/06/18/index.html``. + This is used internally by Pelican and is usually + not relevant to themes. +articles A list of :ref:`Article ` objects + that fall under the time period. +dates Same list as ``articles``, but ordered according + to the ``NEWEST_FIRST_ARCHIVES`` setting. +=================== =================================================== + +Here is an example of how ``period_archives`` can be used in a template: + +.. code-block:: html+jinja + + + +You can change ``period_archives.month`` in the ``for`` statement to +``period_archives.year`` or ``period_archives.day`` as appropriate, depending +on the time period granularity desired. + + Objects ======= diff --git a/docs/tips.rst b/docs/tips.rst index 8b9dda15..904e5ee7 100644 --- a/docs/tips.rst +++ b/docs/tips.rst @@ -34,17 +34,30 @@ settings on your AWS console. From there:: Error Document: 404.html -Publishing to GitHub -==================== +Publishing to GitHub Pages +========================== -`GitHub Pages `_ offer an easy -and convenient way to publish Pelican sites. There are `two types of GitHub -Pages `_: -*Project Pages* and *User Pages*. Pelican sites can be published as both -Project Pages and User Pages. +If you use `GitHub `_ for your Pelican site you can +publish your site to `GitHub Pages `_ for free. +Your site will be published to ``https://.github.io`` if it's a user or +organization site or to ``https://.github.io/`` if it's a +project site. It's also possible to `use a custom domain with GitHub Pages `_. -Project Pages -------------- +There are `two ways to publish a site to GitHub Pages `_: + +1. **Publishing from a branch:** run ``pelican`` locally and push the output + directory to a special branch of your GitHub repo. GitHub will then publish + the contents of this branch to your GitHub Pages site. +2. **Publishing with a custom GitHub Actions workflow:** just push the source + files of your Pelican site to your GitHub repo's default branch and have a + custom GitHub Actions workflow run ``pelican`` for you to generate the + output directory and publish it to your GitHub Pages site. This way you + don't need to run ``pelican`` locally. You can even edit your site's source + files using GitHub's web interface and any changes that you commit will be + published. + +Publishing a Project Site to GitHub Pages from a Branch +------------------------------------------------------- To publish a Pelican site as a Project Page you need to *push* the content of the ``output`` dir generated by Pelican to a repository's ``gh-pages`` branch @@ -72,8 +85,8 @@ already exist). The ``git push origin gh-pages`` command updates the remote ``tasks.py``) created by the ``pelican-quickstart`` command publishes the Pelican site as Project Pages, as described above. -User Pages ----------- +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 @@ -110,6 +123,87 @@ branch of your GitHub repository:: (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: + +1. Enable GitHub Pages in your repo: go to **Settings → Pages** and choose + **GitHub Actions** for the **Source** setting. + +2. Commit a ``.github/workflows/pelican.yml`` file to your repo with these contents: + + .. code-block:: yaml + + name: Deploy to GitHub Pages + on: + push: + branches: ["main"] + workflow_dispatch: + jobs: + deploy: + uses: "getpelican/pelican/.github/workflows/github_pages.yml@master" + permissions: + contents: "read" + pages: "write" + id-token: "write" + with: + settings: "publishconf.py" + +3. Go to the **Actions** tab in your repo + (``https://github.com///actions``) and you should see a + **Deploy to GitHub Pages** action running. + +4. Once the action completes you should see your Pelican site deployed at your + repo's GitHub Pages URL: ``https://.github.io`` for a user or + organization site or ``https://.github.io/>`` for a + project site. + +Notes: + +* You don't need to set ``SITEURL`` in your Pelican settings: the workflow will + set it 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 + directory for you on GitHub Actions + +See `GitHub's docs about reusable workflows `_ +for more information. + +A number of optional inputs can be added to the ``with:`` block when calling +the workflow: + ++--------------+----------+-----------------------------------+--------+---------------+ +| Name | Required | Description | Type | Default | ++==============+==========+===================================+========+===============+ +| settings | Yes | The path to your Pelican settings | string | | +| | | file (``pelican``'s | | | +| | | ``--settings`` option), | | | +| | | for example: ``"publishconf.py"`` | | | ++--------------+----------+-----------------------------------+--------+---------------+ +| requirements | No | The Python requirements to | string | ``"pelican"`` | +| | | install, for example to enable | | | +| | | markdown and typogrify use: | | | +| | | ``"pelican[markdown] typogrify"`` | | | +| | | or if you have a requirements | | | +| | | file: ``"-r requirements.txt"`` | | | ++--------------+----------+-----------------------------------+--------+---------------+ +| output-path | No | Where to output the generated | string | ``"output/"`` | +| | | files (``pelican``'s ``--output`` | | | +| | | option) | | | ++--------------+----------+-----------------------------------+--------+---------------+ + +For example: + +.. code-block:: yaml + + with: + settings: "publishconf.py" + requirements: "pelican[markdown] typogrify" + output-path: "__output__/" + Custom 404 Pages ---------------- diff --git a/pelican/__init__.py b/pelican/__init__.py index bd867988..a25f5624 100644 --- a/pelican/__init__.py +++ b/pelican/__init__.py @@ -1,4 +1,5 @@ import argparse +import importlib.metadata import json import logging import multiprocessing @@ -8,39 +9,43 @@ import sys import time import traceback from collections.abc import Iterable + # Combines all paths to `pelican` package accessible from `sys.path` # Makes it possible to install `pelican` and namespace plugins into different # locations in the file system (e.g. pip with `-e` or `--user`) from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) # pelican.log has to be the first pelican module to be loaded # because logging.setLoggerClass has to be called before logging.getLogger from pelican.log import console from pelican.log import init as init_logging -from pelican.generators import (ArticlesGenerator, # noqa: I100 - PagesGenerator, SourceFileGenerator, - StaticGenerator, TemplatePagesGenerator) +from pelican.generators import ( + ArticlesGenerator, # noqa: I100 + PagesGenerator, + SourceFileGenerator, + StaticGenerator, + TemplatePagesGenerator, +) from pelican.plugins import signals from pelican.plugins._utils import get_plugin_name, load_plugins from pelican.readers import Readers from pelican.server import ComplexHTTPRequestHandler, RootedHTTPServer from pelican.settings import read_settings -from pelican.utils import (FileSystemWatcher, clean_output_dir, maybe_pluralize) +from pelican.utils import clean_output_dir, maybe_pluralize, wait_for_changes from pelican.writers import Writer try: - __version__ = __import__('pkg_resources') \ - .get_distribution('pelican').version + __version__ = importlib.metadata.version("pelican") except Exception: __version__ = "unknown" -DEFAULT_CONFIG_NAME = 'pelicanconf.py' +DEFAULT_CONFIG_NAME = "pelicanconf.py" logger = logging.getLogger(__name__) class Pelican: - def __init__(self, settings): """Pelican initialization @@ -50,35 +55,34 @@ class Pelican: # define the default settings self.settings = settings - self.path = settings['PATH'] - self.theme = settings['THEME'] - self.output_path = settings['OUTPUT_PATH'] - self.ignore_files = settings['IGNORE_FILES'] - self.delete_outputdir = settings['DELETE_OUTPUT_DIRECTORY'] - self.output_retention = settings['OUTPUT_RETENTION'] + self.path = settings["PATH"] + self.theme = settings["THEME"] + self.output_path = settings["OUTPUT_PATH"] + self.ignore_files = settings["IGNORE_FILES"] + self.delete_outputdir = settings["DELETE_OUTPUT_DIRECTORY"] + self.output_retention = settings["OUTPUT_RETENTION"] self.init_path() self.init_plugins() signals.initialized.send(self) def init_path(self): - if not any(p in sys.path for p in ['', os.curdir]): + if not any(p in sys.path for p in ["", os.curdir]): logger.debug("Adding current directory to system path") - sys.path.insert(0, '') + sys.path.insert(0, "") def init_plugins(self): self.plugins = [] for plugin in load_plugins(self.settings): name = get_plugin_name(plugin) - logger.debug('Registering plugin `%s`', name) + logger.debug("Registering plugin `%s`", name) try: plugin.register() self.plugins.append(plugin) except Exception as e: - logger.error('Cannot register plugin `%s`\n%s', - name, e) + logger.error("Cannot register plugin `%s`\n%s", name, e) - self.settings['PLUGINS'] = [get_plugin_name(p) for p in self.plugins] + self.settings["PLUGINS"] = [get_plugin_name(p) for p in self.plugins] def run(self): """Run the generators and return""" @@ -87,10 +91,10 @@ class Pelican: context = self.settings.copy() # Share these among all the generators and content objects # They map source paths to Content objects or None - context['generated_content'] = {} - context['static_links'] = set() - context['static_content'] = {} - context['localsiteurl'] = self.settings['SITEURL'] + context["generated_content"] = {} + context["static_links"] = set() + context["static_content"] = {} + context["localsiteurl"] = self.settings["SITEURL"] generators = [ cls( @@ -99,23 +103,25 @@ class Pelican: path=self.path, theme=self.theme, output_path=self.output_path, - ) for cls in self._get_generator_classes() + ) + for cls in self._get_generator_classes() ] # Delete the output directory if (1) the appropriate setting is True # and (2) that directory is not the parent of the source directory - if (self.delete_outputdir - and os.path.commonpath([os.path.realpath(self.output_path)]) != - os.path.commonpath([os.path.realpath(self.output_path), - os.path.realpath(self.path)])): + if self.delete_outputdir and os.path.commonpath( + [os.path.realpath(self.output_path)] + ) != os.path.commonpath( + [os.path.realpath(self.output_path), os.path.realpath(self.path)] + ): clean_output_dir(self.output_path, self.output_retention) for p in generators: - if hasattr(p, 'generate_context'): + if hasattr(p, "generate_context"): p.generate_context() for p in generators: - if hasattr(p, 'refresh_metadata_intersite_links'): + if hasattr(p, "refresh_metadata_intersite_links"): p.refresh_metadata_intersite_links() signals.all_generators_finalized.send(generators) @@ -123,61 +129,75 @@ class Pelican: writer = self._get_writer() for p in generators: - if hasattr(p, 'generate_output'): + if hasattr(p, "generate_output"): p.generate_output(writer) signals.finalized.send(self) - articles_generator = next(g for g in generators - if isinstance(g, ArticlesGenerator)) - pages_generator = next(g for g in generators - if isinstance(g, PagesGenerator)) + articles_generator = next( + g for g in generators if isinstance(g, ArticlesGenerator) + ) + pages_generator = next(g for g in generators if isinstance(g, PagesGenerator)) pluralized_articles = maybe_pluralize( - (len(articles_generator.articles) + - len(articles_generator.translations)), - 'article', - 'articles') + (len(articles_generator.articles) + len(articles_generator.translations)), + "article", + "articles", + ) pluralized_drafts = maybe_pluralize( - (len(articles_generator.drafts) + - len(articles_generator.drafts_translations)), - 'draft', - 'drafts') + ( + len(articles_generator.drafts) + + len(articles_generator.drafts_translations) + ), + "draft", + "drafts", + ) pluralized_hidden_articles = maybe_pluralize( - (len(articles_generator.hidden_articles) + - len(articles_generator.hidden_translations)), - 'hidden article', - 'hidden articles') + ( + len(articles_generator.hidden_articles) + + len(articles_generator.hidden_translations) + ), + "hidden article", + "hidden articles", + ) pluralized_pages = maybe_pluralize( - (len(pages_generator.pages) + - len(pages_generator.translations)), - 'page', - 'pages') + (len(pages_generator.pages) + len(pages_generator.translations)), + "page", + "pages", + ) pluralized_hidden_pages = maybe_pluralize( - (len(pages_generator.hidden_pages) + - len(pages_generator.hidden_translations)), - 'hidden page', - 'hidden pages') + ( + len(pages_generator.hidden_pages) + + len(pages_generator.hidden_translations) + ), + "hidden page", + "hidden pages", + ) pluralized_draft_pages = maybe_pluralize( - (len(pages_generator.draft_pages) + - len(pages_generator.draft_translations)), - 'draft page', - 'draft pages') + ( + len(pages_generator.draft_pages) + + len(pages_generator.draft_translations) + ), + "draft page", + "draft pages", + ) - 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)) + 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, + ) + ) def _get_generator_classes(self): discovered_generators = [ (ArticlesGenerator, "internal"), - (PagesGenerator, "internal") + (PagesGenerator, "internal"), ] if self.settings["TEMPLATE_PAGES"]: @@ -236,7 +256,7 @@ class PrintSettings(argparse.Action): except Exception as e: logger.critical("%s: %s", e.__class__.__name__, e) console.print_exception() - sys.exit(getattr(e, 'exitcode', 1)) + sys.exit(getattr(e, "exitcode", 1)) if values: # One or more arguments provided, so only print those settings @@ -244,14 +264,16 @@ class PrintSettings(argparse.Action): if setting in settings: # Only add newline between setting name and value if dict if isinstance(settings[setting], (dict, tuple, list)): - setting_format = '\n{}:\n{}' + setting_format = "\n{}:\n{}" else: - setting_format = '\n{}: {}' - console.print(setting_format.format( - setting, - pprint.pformat(settings[setting]))) + setting_format = "\n{}: {}" + console.print( + setting_format.format( + setting, pprint.pformat(settings[setting]) + ) + ) else: - console.print('\n{} is not a recognized setting.'.format(setting)) + console.print(f"\n{setting} is not a recognized setting.") break else: # No argument was given to --print-settings, so print all settings @@ -268,170 +290,247 @@ class ParseOverrides(argparse.Action): k, v = item.split("=", 1) except ValueError: raise ValueError( - 'Extra settings must be specified as KEY=VALUE pairs ' - f'but you specified {item}' + "Extra settings must be specified as KEY=VALUE pairs " + f"but you specified {item}" ) try: overrides[k] = json.loads(v) except json.decoder.JSONDecodeError: raise ValueError( - f'Invalid JSON value: {v}. ' - 'Values specified via -e / --extra-settings flags ' - 'must be in JSON notation. ' - '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).' + f"Invalid JSON value: {v}. " + "Values specified via -e / --extra-settings flags " + "must be in JSON notation. " + "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)." ) setattr(namespace, self.dest, overrides) def parse_arguments(argv=None): parser = argparse.ArgumentParser( - description='A tool to generate a static blog, ' - ' with restructured text input files.', - formatter_class=argparse.ArgumentDefaultsHelpFormatter + description="A tool to generate a static blog, " + " with restructured text input files.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - parser.add_argument(dest='path', nargs='?', - help='Path where to find the content files.', - default=None) + parser.add_argument( + dest="path", + nargs="?", + help="Path where to find the content files.", + default=None, + ) - parser.add_argument('-t', '--theme-path', dest='theme', - help='Path where to find the theme templates. If not ' - 'specified, it will use the default one included with ' - 'pelican.') + parser.add_argument( + "-t", + "--theme-path", + dest="theme", + help="Path where to find the theme templates. If not " + "specified, it will use the default one included with " + "pelican.", + ) - parser.add_argument('-o', '--output', dest='output', - help='Where to output the generated files. If not ' - 'specified, a directory will be created, named ' - '"output" in the current path.') + parser.add_argument( + "-o", + "--output", + dest="output", + help="Where to output the generated files. If not " + "specified, a directory will be created, named " + '"output" in the current path.', + ) - parser.add_argument('-s', '--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)) + parser.add_argument( + "-s", + "--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), + ) - parser.add_argument('-d', '--delete-output-directory', - dest='delete_outputdir', action='store_true', - default=None, help='Delete the output directory.') + parser.add_argument( + "-d", + "--delete-output-directory", + dest="delete_outputdir", + action="store_true", + default=None, + help="Delete the output directory.", + ) - parser.add_argument('-v', '--verbose', action='store_const', - const=logging.INFO, dest='verbosity', - help='Show all messages.') + parser.add_argument( + "-v", + "--verbose", + action="store_const", + const=logging.INFO, + dest="verbosity", + help="Show all messages.", + ) - parser.add_argument('-q', '--quiet', action='store_const', - const=logging.CRITICAL, dest='verbosity', - help='Show only critical errors.') + parser.add_argument( + "-q", + "--quiet", + action="store_const", + const=logging.CRITICAL, + dest="verbosity", + help="Show only critical errors.", + ) - parser.add_argument('-D', '--debug', action='store_const', - const=logging.DEBUG, dest='verbosity', - help='Show all messages, including debug messages.') + parser.add_argument( + "-D", + "--debug", + action="store_const", + const=logging.DEBUG, + dest="verbosity", + help="Show all messages, including debug messages.", + ) - parser.add_argument('--version', action='version', version=__version__, - help='Print the pelican version and exit.') + parser.add_argument( + "--version", + action="version", + version=__version__, + help="Print the pelican version and exit.", + ) - parser.add_argument('-r', '--autoreload', dest='autoreload', - action='store_true', - help='Relaunch pelican each time a modification occurs' - ' on the content files.') + parser.add_argument( + "-r", + "--autoreload", + dest="autoreload", + action="store_true", + help="Relaunch pelican each time a modification occurs" + " on the content files.", + ) - parser.add_argument('--print-settings', dest='print_settings', nargs='*', - action=PrintSettings, metavar='SETTING_NAME', - help='Print current configuration settings and exit. ' - 'Append one or more setting name arguments to see the ' - 'values for specific settings only.') + parser.add_argument( + "--print-settings", + dest="print_settings", + nargs="*", + action=PrintSettings, + metavar="SETTING_NAME", + help="Print current configuration settings and exit. " + "Append one or more setting name arguments to see the " + "values for specific settings only.", + ) - parser.add_argument('--relative-urls', dest='relative_paths', - action='store_true', - help='Use relative urls in output, ' - 'useful for site development') + parser.add_argument( + "--relative-urls", + dest="relative_paths", + action="store_true", + help="Use relative urls in output, " "useful for site development", + ) - parser.add_argument('--cache-path', dest='cache_path', - help=('Directory in which to store cache files. ' - 'If not specified, defaults to "cache".')) + parser.add_argument( + "--cache-path", + dest="cache_path", + help=( + "Directory in which to store cache files. " + 'If not specified, defaults to "cache".' + ), + ) - parser.add_argument('--ignore-cache', action='store_true', - dest='ignore_cache', help='Ignore content cache ' - 'from previous runs by not loading cache files.') + parser.add_argument( + "--ignore-cache", + action="store_true", + dest="ignore_cache", + help="Ignore content cache " "from previous runs by not loading cache files.", + ) - parser.add_argument('-w', '--write-selected', type=str, - dest='selected_paths', default=None, - help='Comma separated list of selected paths to write') + parser.add_argument( + "--fatal", + metavar="errors|warnings", + choices=("errors", "warnings"), + default="", + help=( + "Exit the program with non-zero status if any " + "errors/warnings encountered." + ), + ) - parser.add_argument('--fatal', metavar='errors|warnings', - choices=('errors', 'warnings'), default='', - help=('Exit the program with non-zero status if any ' - 'errors/warnings encountered.')) + parser.add_argument( + "--logs-dedup-min-level", + default="WARNING", + choices=("DEBUG", "INFO", "WARNING", "ERROR"), + help=( + "Only enable log de-duplication for levels equal" + " to or above the specified value" + ), + ) - parser.add_argument('--logs-dedup-min-level', default='WARNING', - choices=('DEBUG', 'INFO', 'WARNING', 'ERROR'), - help=('Only enable log de-duplication for levels equal' - ' to or above the specified value')) + parser.add_argument( + "-l", + "--listen", + dest="listen", + action="store_true", + help="Serve content files via HTTP and port 8000.", + ) - parser.add_argument('-l', '--listen', dest='listen', action='store_true', - help='Serve content files via HTTP and port 8000.') + parser.add_argument( + "-p", + "--port", + dest="port", + type=int, + help="Port to serve HTTP files at. (default: 8000)", + ) - parser.add_argument('-p', '--port', dest='port', type=int, - help='Port to serve HTTP files at. (default: 8000)') + parser.add_argument( + "-b", + "--bind", + dest="bind", + help="IP to bind to when serving files via HTTP " "(default: 127.0.0.1)", + ) - parser.add_argument('-b', '--bind', dest='bind', - help='IP to bind to when serving files via HTTP ' - '(default: 127.0.0.1)') - - parser.add_argument('-e', '--extra-settings', dest='overrides', - help='Specify one or more SETTING=VALUE pairs to ' - 'override settings. VALUE must be in JSON notation: ' - 'specify string values as SETTING=\'"some string"\'; ' - 'booleans as SETTING=true or SETTING=false; ' - 'None as SETTING=null.', - nargs='*', - action=ParseOverrides, - default={}) + parser.add_argument( + "-e", + "--extra-settings", + dest="overrides", + help="Specify one or more SETTING=VALUE pairs to " + "override settings. VALUE must be in JSON notation: " + "specify string values as SETTING='\"some string\"'; " + "booleans as SETTING=true or SETTING=false; " + "None as SETTING=null.", + nargs="*", + action=ParseOverrides, + default={}, + ) args = parser.parse_args(argv) if args.port is not None and not args.listen: - logger.warning('--port without --listen has no effect') + logger.warning("--port without --listen has no effect") if args.bind is not None and not args.listen: - logger.warning('--bind without --listen has no effect') + logger.warning("--bind without --listen has no effect") return args def get_config(args): - """Builds a config dictionary based on supplied `args`. - """ + """Builds a config dictionary based on supplied `args`.""" config = {} if args.path: - config['PATH'] = os.path.abspath(os.path.expanduser(args.path)) + config["PATH"] = os.path.abspath(os.path.expanduser(args.path)) if args.output: - config['OUTPUT_PATH'] = \ - os.path.abspath(os.path.expanduser(args.output)) + config["OUTPUT_PATH"] = os.path.abspath(os.path.expanduser(args.output)) if args.theme: abstheme = os.path.abspath(os.path.expanduser(args.theme)) - config['THEME'] = abstheme if os.path.exists(abstheme) else args.theme + config["THEME"] = abstheme if os.path.exists(abstheme) else args.theme if args.delete_outputdir is not None: - config['DELETE_OUTPUT_DIRECTORY'] = args.delete_outputdir + config["DELETE_OUTPUT_DIRECTORY"] = args.delete_outputdir if args.ignore_cache: - config['LOAD_CONTENT_CACHE'] = False + config["LOAD_CONTENT_CACHE"] = False if args.cache_path: - config['CACHE_PATH'] = args.cache_path - if args.selected_paths: - config['WRITE_SELECTED'] = args.selected_paths.split(',') + config["CACHE_PATH"] = args.cache_path if args.relative_paths: - config['RELATIVE_URLS'] = args.relative_paths + config["RELATIVE_URLS"] = args.relative_paths if args.port is not None: - config['PORT'] = args.port + config["PORT"] = args.port if args.bind is not None: - config['BIND'] = args.bind - config['DEBUG'] = args.verbosity == logging.DEBUG + config["BIND"] = args.bind + config["DEBUG"] = args.verbosity == logging.DEBUG config.update(args.overrides) return config def get_instance(args): - config_file = args.settings if config_file is None and os.path.isfile(DEFAULT_CONFIG_NAME): config_file = DEFAULT_CONFIG_NAME @@ -439,9 +538,9 @@ def get_instance(args): settings = read_settings(config_file, override=get_config(args)) - cls = settings['PELICAN_CLASS'] + cls = settings["PELICAN_CLASS"] if isinstance(cls, str): - module, cls_name = cls.rsplit('.', 1) + module, cls_name = cls.rsplit(".", 1) module = __import__(module) cls = getattr(module, cls_name) @@ -449,29 +548,25 @@ def get_instance(args): def autoreload(args, excqueue=None): - console.print(' --- AutoReload Mode: Monitoring `content`, `theme` and' - ' `settings` for changes. ---') + console.print( + " --- AutoReload Mode: Monitoring `content`, `theme` and" + " `settings` for changes. ---" + ) pelican, settings = get_instance(args) - watcher = FileSystemWatcher(args.settings, Readers, settings) - sleep = False + settings_file = os.path.abspath(args.settings) while True: try: - # Don't sleep first time, but sleep afterwards to reduce cpu load - if sleep: - time.sleep(0.5) - else: - sleep = True + pelican.run() - modified = watcher.check() + changed_files = wait_for_changes(args.settings, Readers, settings) + changed_files = {c[1] for c in changed_files} - if modified['settings']: + if settings_file in changed_files: pelican, settings = get_instance(args) - watcher.update_watchers(settings) - if any(modified.values()): - console.print('\n-> Modified: {}. re-generating...'.format( - ', '.join(k for k, v in modified.items() if v))) - pelican.run() + console.print( + "\n-> Modified: {}. re-generating...".format(", ".join(changed_files)) + ) except KeyboardInterrupt: if excqueue is not None: @@ -480,15 +575,14 @@ def autoreload(args, excqueue=None): raise except Exception as e: - if (args.verbosity == logging.DEBUG): + if args.verbosity == logging.DEBUG: if excqueue is not None: - excqueue.put( - traceback.format_exception_only(type(e), e)[-1]) + excqueue.put(traceback.format_exception_only(type(e), e)[-1]) else: raise logger.warning( - 'Caught exception:\n"%s".', e, - exc_info=settings.get('DEBUG', False)) + 'Caught exception:\n"%s".', e, exc_info=settings.get("DEBUG", False) + ) def listen(server, port, output, excqueue=None): @@ -498,8 +592,7 @@ def listen(server, port, output, excqueue=None): RootedHTTPServer.allow_reuse_address = True try: - httpd = RootedHTTPServer( - output, (server, port), ComplexHTTPRequestHandler) + httpd = RootedHTTPServer(output, (server, port), ComplexHTTPRequestHandler) except OSError as e: logging.error("Could not listen on port %s, server %s.", port, server) if excqueue is not None: @@ -507,8 +600,7 @@ def listen(server, port, output, excqueue=None): return try: - console.print("Serving site at: http://{}:{} - Tap CTRL-C to stop".format( - server, port)) + console.print(f"Serving site at: http://{server}:{port} - Tap CTRL-C to stop") httpd.serve_forever() except Exception as e: if excqueue is not None: @@ -525,24 +617,31 @@ def listen(server, port, output, excqueue=None): def main(argv=None): args = parse_arguments(argv) logs_dedup_min_level = getattr(logging, args.logs_dedup_min_level) - init_logging(level=args.verbosity, fatal=args.fatal, - name=__name__, logs_dedup_min_level=logs_dedup_min_level) + init_logging( + level=args.verbosity, + fatal=args.fatal, + name=__name__, + logs_dedup_min_level=logs_dedup_min_level, + ) - logger.debug('Pelican version: %s', __version__) - logger.debug('Python version: %s', sys.version.split()[0]) + logger.debug("Pelican version: %s", __version__) + logger.debug("Python version: %s", sys.version.split()[0]) try: pelican, settings = get_instance(args) if args.autoreload and args.listen: excqueue = multiprocessing.Queue() - p1 = multiprocessing.Process( - target=autoreload, - args=(args, excqueue)) + p1 = multiprocessing.Process(target=autoreload, args=(args, excqueue)) p2 = multiprocessing.Process( target=listen, - args=(settings.get('BIND'), settings.get('PORT'), - settings.get("OUTPUT_PATH"), excqueue)) + args=( + settings.get("BIND"), + settings.get("PORT"), + settings.get("OUTPUT_PATH"), + excqueue, + ), + ) try: p1.start() p2.start() @@ -555,18 +654,17 @@ def main(argv=None): elif args.autoreload: autoreload(args) elif args.listen: - listen(settings.get('BIND'), settings.get('PORT'), - settings.get("OUTPUT_PATH")) + listen( + settings.get("BIND"), settings.get("PORT"), settings.get("OUTPUT_PATH") + ) else: - watcher = FileSystemWatcher(args.settings, Readers, settings) - watcher.check() with console.status("Generating..."): pelican.run() except KeyboardInterrupt: - logger.warning('Keyboard interrupt received. Exiting.') + logger.warning("Keyboard interrupt received. Exiting.") except Exception as e: logger.critical("%s: %s", e.__class__.__name__, e) if args.verbosity == logging.DEBUG: console.print_exception() - sys.exit(getattr(e, 'exitcode', 1)) + sys.exit(getattr(e, "exitcode", 1)) diff --git a/pelican/__main__.py b/pelican/__main__.py index 69a5b95d..17aead3b 100644 --- a/pelican/__main__.py +++ b/pelican/__main__.py @@ -5,5 +5,5 @@ python -m pelican module entry point to run via python -m from . import main -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/pelican/cache.py b/pelican/cache.py index d2665691..d1f8550e 100644 --- a/pelican/cache.py +++ b/pelican/cache.py @@ -19,29 +19,35 @@ class FileDataCacher: Sets caching policy according to *caching_policy*. """ self.settings = settings - self._cache_path = os.path.join(self.settings['CACHE_PATH'], - cache_name) + self._cache_path = os.path.join(self.settings["CACHE_PATH"], cache_name) self._cache_data_policy = caching_policy - if self.settings['GZIP_CACHE']: + if self.settings["GZIP_CACHE"]: import gzip + self._cache_open = gzip.open else: self._cache_open = open if load_policy: try: - with self._cache_open(self._cache_path, 'rb') as fhandle: + with self._cache_open(self._cache_path, "rb") as fhandle: self._cache = pickle.load(fhandle) except (OSError, UnicodeDecodeError) as err: - logger.debug('Cannot load cache %s (this is normal on first ' - 'run). Proceeding with empty cache.\n%s', - self._cache_path, err) + logger.debug( + "Cannot load cache %s (this is normal on first " + "run). Proceeding with empty cache.\n%s", + self._cache_path, + err, + ) self._cache = {} except pickle.PickleError as err: - logger.warning('Cannot unpickle cache %s, cache may be using ' - 'an incompatible protocol (see pelican ' - 'caching docs). ' - 'Proceeding with empty cache.\n%s', - self._cache_path, err) + logger.warning( + "Cannot unpickle cache %s, cache may be using " + "an incompatible protocol (see pelican " + "caching docs). " + "Proceeding with empty cache.\n%s", + self._cache_path, + err, + ) self._cache = {} else: self._cache = {} @@ -62,12 +68,13 @@ class FileDataCacher: """Save the updated cache""" if self._cache_data_policy: try: - mkdir_p(self.settings['CACHE_PATH']) - with self._cache_open(self._cache_path, 'wb') as fhandle: + mkdir_p(self.settings["CACHE_PATH"]) + with self._cache_open(self._cache_path, "wb") as fhandle: pickle.dump(self._cache, fhandle) except (OSError, pickle.PicklingError, TypeError) as err: - logger.warning('Could not save cache %s\n ... %s', - self._cache_path, err) + logger.warning( + "Could not save cache %s\n ... %s", self._cache_path, err + ) class FileStampDataCacher(FileDataCacher): @@ -80,8 +87,8 @@ class FileStampDataCacher(FileDataCacher): super().__init__(settings, cache_name, caching_policy, load_policy) - method = self.settings['CHECK_MODIFIED_METHOD'] - if method == 'mtime': + method = self.settings["CHECK_MODIFIED_METHOD"] + if method == "mtime": self._filestamp_func = os.path.getmtime else: try: @@ -89,12 +96,12 @@ class FileStampDataCacher(FileDataCacher): def filestamp_func(filename): """return hash of file contents""" - with open(filename, 'rb') as fhandle: + with open(filename, "rb") as fhandle: return hash_func(fhandle.read()).digest() self._filestamp_func = filestamp_func except AttributeError as err: - logger.warning('Could not get hashing function\n\t%s', err) + logger.warning("Could not get hashing function\n\t%s", err) self._filestamp_func = None def cache_data(self, filename, data): @@ -115,9 +122,8 @@ class FileStampDataCacher(FileDataCacher): try: return self._filestamp_func(filename) except (OSError, TypeError) as err: - logger.warning('Cannot get modification stamp for %s\n\t%s', - filename, err) - return '' + logger.warning("Cannot get modification stamp for %s\n\t%s", filename, err) + return "" def get_cached_data(self, filename, default=None): """Get the cached data for the given filename diff --git a/pelican/contents.py b/pelican/contents.py index 4541e2ae..474e5bbf 100644 --- a/pelican/contents.py +++ b/pelican/contents.py @@ -16,12 +16,19 @@ except ModuleNotFoundError: from pelican.plugins import signals from pelican.settings import DEFAULT_CONFIG -from pelican.utils import (deprecated_attribute, memoized, path_to_url, - posixize_path, sanitised_join, set_date_tzinfo, - slugify, truncate_html_words) +from pelican.utils import ( + deprecated_attribute, + memoized, + path_to_url, + posixize_path, + sanitised_join, + set_date_tzinfo, + slugify, + 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 +from pelican.urlwrappers import Author, Category, Tag, URLWrapper # NOQA logger = logging.getLogger(__name__) @@ -36,12 +43,14 @@ class Content: :param context: The shared context between generators. """ - @deprecated_attribute(old='filename', new='source_path', since=(3, 2, 0)) + + @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): + def __init__( + self, content, metadata=None, settings=None, source_path=None, context=None + ): if metadata is None: metadata = {} if settings is None: @@ -59,8 +68,8 @@ class Content: # set metadata as attributes for key, value in local_metadata.items(): - if key in ('save_as', 'url'): - key = 'override_' + key + if key in ("save_as", "url"): + key = "override_" + key setattr(self, key.lower(), value) # also keep track of the metadata attributes available @@ -71,53 +80,52 @@ class Content: # First, read the authors from "authors", if not, fallback to "author" # and if not use the settings defined one, if any. - if not hasattr(self, 'author'): - if hasattr(self, 'authors'): + if not hasattr(self, "author"): + if hasattr(self, "authors"): self.author = self.authors[0] - elif 'AUTHOR' in settings: - self.author = Author(settings['AUTHOR'], settings) + elif "AUTHOR" in settings: + self.author = Author(settings["AUTHOR"], settings) - if not hasattr(self, 'authors') and hasattr(self, 'author'): + if not hasattr(self, "authors") and hasattr(self, "author"): self.authors = [self.author] # XXX Split all the following code into pieces, there is too much here. # manage languages self.in_default_lang = True - if 'DEFAULT_LANG' in settings: - default_lang = settings['DEFAULT_LANG'].lower() - if not hasattr(self, 'lang'): + if "DEFAULT_LANG" in settings: + default_lang = settings["DEFAULT_LANG"].lower() + if not hasattr(self, "lang"): self.lang = default_lang - self.in_default_lang = (self.lang == default_lang) + self.in_default_lang = self.lang == default_lang # create the slug if not existing, generate slug according to # setting of SLUG_ATTRIBUTE - if not hasattr(self, 'slug'): - if (settings['SLUGIFY_SOURCE'] == 'title' and - hasattr(self, 'title')): + if not hasattr(self, "slug"): + if settings["SLUGIFY_SOURCE"] == "title" and hasattr(self, "title"): value = self.title - elif (settings['SLUGIFY_SOURCE'] == 'basename' and - source_path is not None): + elif settings["SLUGIFY_SOURCE"] == "basename" and source_path is not None: value = os.path.basename(os.path.splitext(source_path)[0]) else: value = None if value is not None: self.slug = slugify( value, - regex_subs=settings.get('SLUG_REGEX_SUBSTITUTIONS', []), - preserve_case=settings.get('SLUGIFY_PRESERVE_CASE', False), - use_unicode=settings.get('SLUGIFY_USE_UNICODE', False)) + regex_subs=settings.get("SLUG_REGEX_SUBSTITUTIONS", []), + preserve_case=settings.get("SLUGIFY_PRESERVE_CASE", False), + use_unicode=settings.get("SLUGIFY_USE_UNICODE", False), + ) self.source_path = source_path self.relative_source_path = self.get_relative_source_path() # manage the date format - if not hasattr(self, 'date_format'): - if hasattr(self, 'lang') and self.lang in settings['DATE_FORMATS']: - self.date_format = settings['DATE_FORMATS'][self.lang] + if not hasattr(self, "date_format"): + if hasattr(self, "lang") and self.lang in settings["DATE_FORMATS"]: + self.date_format = settings["DATE_FORMATS"][self.lang] else: - self.date_format = settings['DEFAULT_DATE_FORMAT'] + self.date_format = settings["DEFAULT_DATE_FORMAT"] if isinstance(self.date_format, tuple): locale_string = self.date_format[0] @@ -129,22 +137,22 @@ class Content: timezone = getattr(self, "timezone", default_timezone) self.timezone = ZoneInfo(timezone) - if hasattr(self, 'date'): + if hasattr(self, "date"): self.date = set_date_tzinfo(self.date, timezone) self.locale_date = self.date.strftime(self.date_format) - if hasattr(self, 'modified'): + if hasattr(self, "modified"): self.modified = set_date_tzinfo(self.modified, timezone) self.locale_modified = self.modified.strftime(self.date_format) # manage status - if not hasattr(self, 'status'): + if not hasattr(self, "status"): # Previous default of None broke comment plugins and perhaps others - self.status = getattr(self, 'default_status', '') + self.status = getattr(self, "default_status", "") # store the summary metadata if it is set - if 'summary' in metadata: - self._summary = metadata['summary'] + if "summary" in metadata: + self._summary = metadata["summary"] signals.content_object_init.send(self) @@ -156,8 +164,8 @@ class Content: for prop in self.mandatory_properties: if not hasattr(self, prop): logger.error( - "Skipping %s: could not find information about '%s'", - self, prop) + "Skipping %s: could not find information about '%s'", self, prop + ) return False return True @@ -183,12 +191,13 @@ class Content: return True def _has_valid_status(self): - if hasattr(self, 'allowed_statuses'): + if hasattr(self, "allowed_statuses"): if self.status not in self.allowed_statuses: logger.error( "Unknown status '%s' for file %s, skipping it. (Not in %s)", self.status, - self, self.allowed_statuses + self, + self.allowed_statuses, ) return False @@ -198,42 +207,48 @@ class Content: def is_valid(self): """Validate Content""" # Use all() to not short circuit and get results of all validations - return all([self._has_valid_mandatory_properties(), - self._has_valid_save_as(), - self._has_valid_status()]) + return all( + [ + self._has_valid_mandatory_properties(), + self._has_valid_save_as(), + self._has_valid_status(), + ] + ) @property def url_format(self): """Returns the URL, formatted with the proper values""" metadata = copy.copy(self.metadata) - path = self.metadata.get('path', self.get_relative_source_path()) - metadata.update({ - 'path': path_to_url(path), - 'slug': getattr(self, 'slug', ''), - 'lang': getattr(self, 'lang', 'en'), - 'date': getattr(self, 'date', datetime.datetime.now()), - 'author': self.author.slug if hasattr(self, 'author') else '', - 'category': self.category.slug if hasattr(self, 'category') else '' - }) + path = self.metadata.get("path", self.get_relative_source_path()) + metadata.update( + { + "path": path_to_url(path), + "slug": getattr(self, "slug", ""), + "lang": getattr(self, "lang", "en"), + "date": getattr(self, "date", datetime.datetime.now()), + "author": self.author.slug if hasattr(self, "author") else "", + "category": self.category.slug if hasattr(self, "category") else "", + } + ) return metadata def _expand_settings(self, key, klass=None): if not klass: klass = self.__class__.__name__ - fq_key = ('{}_{}'.format(klass, key)).upper() - return self.settings[fq_key].format(**self.url_format) + fq_key = (f"{klass}_{key}").upper() + return str(self.settings[fq_key]).format(**self.url_format) def get_url_setting(self, key): - if hasattr(self, 'override_' + key): - return getattr(self, 'override_' + key) - key = key if self.in_default_lang else 'lang_%s' % key + if hasattr(self, "override_" + key): + return getattr(self, "override_" + key) + key = key if self.in_default_lang else "lang_%s" % key return self._expand_settings(key) def _link_replacer(self, siteurl, m): - what = m.group('what') - value = urlparse(m.group('value')) + what = m.group("what") + value = urlparse(m.group("value")) path = value.path - origin = m.group('path') + origin = m.group("path") # urllib.parse.urljoin() produces `a.html` for urljoin("..", "a.html") # so if RELATIVE_URLS are enabled, we fall back to os.path.join() to @@ -241,7 +256,7 @@ class Content: # `baz/http://foo/bar.html` for join("baz", "http://foo/bar.html") # instead of correct "http://foo/bar.html", so one has to pick a side # as there is no silver bullet. - if self.settings['RELATIVE_URLS']: + if self.settings["RELATIVE_URLS"]: joiner = os.path.join else: joiner = urljoin @@ -251,16 +266,17 @@ class Content: # os.path.join()), so in order to get a correct answer one needs to # append a trailing slash to siteurl in that case. This also makes # the new behavior fully compatible with Pelican 3.7.1. - if not siteurl.endswith('/'): - siteurl += '/' + if not siteurl.endswith("/"): + siteurl += "/" # XXX Put this in a different location. - if what in {'filename', 'static', 'attach'}: + if what in {"filename", "static", "attach"}: + def _get_linked_content(key, url): nonlocal value def _find_path(path): - if path.startswith('/'): + if path.startswith("/"): path = path[1:] else: # relative to the source path of this content @@ -287,59 +303,64 @@ class Content: return result # check if a static file is linked with {filename} - if what == 'filename' and key == 'generated_content': - linked_content = _get_linked_content('static_content', value) + if what == "filename" and key == "generated_content": + linked_content = _get_linked_content("static_content", value) if linked_content: logger.warning( - '{filename} used for linking to static' - ' content %s in %s. Use {static} instead', + "{filename} used for linking to static" + " content %s in %s. Use {static} instead", value.path, - self.get_relative_source_path()) + self.get_relative_source_path(), + ) return linked_content return None - if what == 'filename': - key = 'generated_content' + if what == "filename": + key = "generated_content" else: - key = 'static_content' + key = "static_content" linked_content = _get_linked_content(key, value) if linked_content: - if what == 'attach': + if what == "attach": linked_content.attach_to(self) origin = joiner(siteurl, linked_content.url) - origin = origin.replace('\\', '/') # for Windows paths. + origin = origin.replace("\\", "/") # for Windows paths. else: logger.warning( "Unable to find '%s', skipping url replacement.", - value.geturl(), extra={ - 'limit_msg': ("Other resources were not found " - "and their urls not replaced")}) - elif what == 'category': + value.geturl(), + extra={ + "limit_msg": ( + "Other resources were not found " + "and their urls not replaced" + ) + }, + ) + elif what == "category": origin = joiner(siteurl, Category(path, self.settings).url) - elif what == 'tag': + elif what == "tag": origin = joiner(siteurl, Tag(path, self.settings).url) - elif what == 'index': - origin = joiner(siteurl, self.settings['INDEX_SAVE_AS']) - elif what == 'author': + elif what == "index": + origin = joiner(siteurl, self.settings["INDEX_SAVE_AS"]) + elif what == "author": origin = joiner(siteurl, Author(path, self.settings).url) else: logger.warning( - "Replacement Indicator '%s' not recognized, " - "skipping replacement", - what) + "Replacement Indicator '%s' not recognized, " "skipping replacement", + what, + ) # keep all other parts, such as query, fragment, etc. parts = list(value) parts[2] = origin origin = urlunparse(parts) - return ''.join((m.group('markup'), m.group('quote'), origin, - m.group('quote'))) + return "".join((m.group("markup"), m.group("quote"), origin, m.group("quote"))) def _get_intrasite_link_regex(self): - intrasite_link_regex = self.settings['INTRASITE_LINK_REGEX'] + intrasite_link_regex = self.settings["INTRASITE_LINK_REGEX"] regex = r""" (?P<[^\>]+ # match tag with all url-value attributes (?:href|src|poster|data|cite|formaction|action|content)\s*=\s*) @@ -369,28 +390,28 @@ class Content: static_links = set() hrefs = self._get_intrasite_link_regex() for m in hrefs.finditer(self._content): - what = m.group('what') - value = urlparse(m.group('value')) + what = m.group("what") + value = urlparse(m.group("value")) path = value.path - if what not in {'static', 'attach'}: + if what not in {"static", "attach"}: continue - if path.startswith('/'): + if path.startswith("/"): path = path[1:] else: # relative to the source path of this content path = self.get_relative_source_path( os.path.join(self.relative_dir, path) ) - path = path.replace('%20', ' ') + path = path.replace("%20", " ") static_links.add(path) return static_links def get_siteurl(self): - return self._context.get('localsiteurl', '') + return self._context.get("localsiteurl", "") @memoized def get_content(self, siteurl): - if hasattr(self, '_get_content'): + if hasattr(self, "_get_content"): content = self._get_content() else: content = self._content @@ -407,15 +428,17 @@ class Content: This is based on the summary metadata if set, otherwise truncate the content. """ - if 'summary' in self.metadata: - return self.metadata['summary'] + if "summary" in self.metadata: + return self.metadata["summary"] - if self.settings['SUMMARY_MAX_LENGTH'] is None: + if self.settings["SUMMARY_MAX_LENGTH"] is None: return self.content - return truncate_html_words(self.content, - self.settings['SUMMARY_MAX_LENGTH'], - self.settings['SUMMARY_END_SUFFIX']) + return truncate_html_words( + self.content, + self.settings["SUMMARY_MAX_LENGTH"], + self.settings["SUMMARY_END_SUFFIX"], + ) @property def summary(self): @@ -424,8 +447,10 @@ class Content: def _get_summary(self): """deprecated function to access summary""" - logger.warning('_get_summary() has been deprecated since 3.6.4. ' - 'Use the summary decorator instead') + logger.warning( + "_get_summary() has been deprecated since 3.6.4. " + "Use the summary decorator instead" + ) return self.summary @summary.setter @@ -444,14 +469,14 @@ class Content: @property def url(self): - return self.get_url_setting('url') + return self.get_url_setting("url") @property def save_as(self): - return self.get_url_setting('save_as') + return self.get_url_setting("save_as") def _get_template(self): - if hasattr(self, 'template') and self.template is not None: + if hasattr(self, "template") and self.template is not None: return self.template else: return self.default_template @@ -470,11 +495,10 @@ class Content: return posixize_path( os.path.relpath( - os.path.abspath(os.path.join( - self.settings['PATH'], - source_path)), - os.path.abspath(self.settings['PATH']) - )) + os.path.abspath(os.path.join(self.settings["PATH"], source_path)), + os.path.abspath(self.settings["PATH"]), + ) + ) @property def relative_dir(self): @@ -482,85 +506,84 @@ class Content: os.path.dirname( os.path.relpath( os.path.abspath(self.source_path), - os.path.abspath(self.settings['PATH'])))) + os.path.abspath(self.settings["PATH"]), + ) + ) + ) def refresh_metadata_intersite_links(self): - 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() - ) + 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()) self.metadata[key] = value 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 + 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 class Page(Content): - mandatory_properties = ('title',) - allowed_statuses = ('published', 'hidden', 'draft') - default_status = 'published' - default_template = 'page' + mandatory_properties = ("title",) + allowed_statuses = ("published", "hidden", "draft") + default_status = "published" + default_template = "page" def _expand_settings(self, key): - klass = 'draft_page' if self.status == 'draft' else None + klass = "draft_page" if self.status == "draft" else None return super()._expand_settings(key, klass) class Article(Content): - mandatory_properties = ('title', 'date', 'category') - allowed_statuses = ('published', 'hidden', 'draft') - default_status = 'published' - default_template = 'article' + mandatory_properties = ("title", "date", "category") + allowed_statuses = ("published", "hidden", "draft") + default_status = "published" + default_template = "article" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # handle WITH_FUTURE_DATES (designate article to draft based on date) - if not self.settings['WITH_FUTURE_DATES'] and hasattr(self, 'date'): + if not self.settings["WITH_FUTURE_DATES"] and hasattr(self, "date"): if self.date.tzinfo is None: now = datetime.datetime.now() else: now = datetime.datetime.utcnow().replace(tzinfo=timezone.utc) if self.date > now: - self.status = 'draft' + self.status = "draft" # if we are a draft and there is no date provided, set max datetime - if not hasattr(self, 'date') and self.status == 'draft': + if not hasattr(self, "date") and self.status == "draft": self.date = datetime.datetime.max.replace(tzinfo=self.timezone) def _expand_settings(self, key): - klass = 'draft' if self.status == 'draft' else 'article' + klass = "draft" if self.status == "draft" else "article" return super()._expand_settings(key, klass) class Static(Content): - mandatory_properties = ('title',) - default_status = 'published' + mandatory_properties = ("title",) + default_status = "published" default_template = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._output_location_referenced = False - @deprecated_attribute(old='filepath', new='source_path', since=(3, 2, 0)) + @deprecated_attribute(old="filepath", new="source_path", since=(3, 2, 0)) def filepath(): return None - @deprecated_attribute(old='src', new='source_path', since=(3, 2, 0)) + @deprecated_attribute(old="src", new="source_path", since=(3, 2, 0)) def src(): return None - @deprecated_attribute(old='dst', new='save_as', since=(3, 2, 0)) + @deprecated_attribute(old="dst", new="save_as", since=(3, 2, 0)) def dst(): return None @@ -577,8 +600,7 @@ class Static(Content): return super().save_as def attach_to(self, content): - """Override our output directory with that of the given content object. - """ + """Override our output directory with that of the given content object.""" # Determine our file's new output path relative to the linking # document. If it currently lives beneath the linking @@ -589,8 +611,7 @@ class Static(Content): tail_path = os.path.relpath(self.source_path, linking_source_dir) if tail_path.startswith(os.pardir + os.sep): tail_path = os.path.basename(tail_path) - new_save_as = os.path.join( - os.path.dirname(content.save_as), tail_path) + new_save_as = os.path.join(os.path.dirname(content.save_as), tail_path) # We do not build our new url by joining tail_path with the linking # document's url, because we cannot know just by looking at the latter @@ -609,12 +630,14 @@ class Static(Content): "%s because %s. Falling back to " "{filename} link behavior instead.", content.get_relative_source_path(), - self.get_relative_source_path(), reason, - extra={'limit_msg': "More {attach} warnings silenced."}) + self.get_relative_source_path(), + reason, + extra={"limit_msg": "More {attach} warnings silenced."}, + ) # We never override an override, because we don't want to interfere # with user-defined overrides that might be in EXTRA_PATH_METADATA. - if hasattr(self, 'override_save_as') or hasattr(self, 'override_url'): + if hasattr(self, "override_save_as") or hasattr(self, "override_url"): if new_save_as != self.save_as or new_url != self.url: _log_reason("its output location was already overridden") return diff --git a/pelican/generators.py b/pelican/generators.py index 4fd796ba..3b5ca9e4 100644 --- a/pelican/generators.py +++ b/pelican/generators.py @@ -8,16 +8,28 @@ from functools import partial from itertools import chain, groupby from operator import attrgetter -from jinja2 import (BaseLoader, ChoiceLoader, Environment, FileSystemLoader, - PrefixLoader, TemplateNotFound) +from jinja2 import ( + BaseLoader, + ChoiceLoader, + Environment, + FileSystemLoader, + PrefixLoader, + TemplateNotFound, +) from pelican.cache import FileStampDataCacher from pelican.contents import Article, Page, Static from pelican.plugins import signals +from pelican.plugins._utils import plugin_enabled from pelican.readers import Readers -from pelican.utils import (DateFormatter, copy, mkdir_p, order_content, - posixize_path, process_translations) - +from pelican.utils import ( + DateFormatter, + copy, + mkdir_p, + order_content, + posixize_path, + process_translations, +) logger = logging.getLogger(__name__) @@ -29,8 +41,16 @@ class PelicanTemplateNotFound(Exception): class Generator: """Baseclass generator""" - def __init__(self, context, settings, path, theme, output_path, - readers_cache_name='', **kwargs): + def __init__( + self, + context, + settings, + path, + theme, + output_path, + readers_cache_name="", + **kwargs, + ): self.context = context self.settings = settings self.path = path @@ -44,44 +64,48 @@ class Generator: # templates cache self._templates = {} - self._templates_path = list(self.settings['THEME_TEMPLATES_OVERRIDES']) + self._templates_path = list(self.settings["THEME_TEMPLATES_OVERRIDES"]) - theme_templates_path = os.path.expanduser( - os.path.join(self.theme, 'templates')) + theme_templates_path = os.path.expanduser(os.path.join(self.theme, "templates")) self._templates_path.append(theme_templates_path) theme_loader = FileSystemLoader(theme_templates_path) simple_theme_path = os.path.dirname(os.path.abspath(__file__)) simple_loader = FileSystemLoader( - os.path.join(simple_theme_path, "themes", "simple", "templates")) - - self.env = Environment( - loader=ChoiceLoader([ - FileSystemLoader(self._templates_path), - simple_loader, # implicit inheritance - PrefixLoader({ - '!simple': simple_loader, - '!theme': theme_loader - }) # explicit ones - ]), - **self.settings['JINJA_ENVIRONMENT'] + os.path.join(simple_theme_path, "themes", "simple", "templates") ) - logger.debug('Template list: %s', self.env.list_templates()) + self.env = Environment( + loader=ChoiceLoader( + [ + FileSystemLoader(self._templates_path), + simple_loader, # implicit inheritance + PrefixLoader( + {"!simple": simple_loader, "!theme": theme_loader} + ), # explicit ones + ] + ), + **self.settings["JINJA_ENVIRONMENT"], + ) + + logger.debug("Template list: %s", self.env.list_templates()) # provide utils.strftime as a jinja filter - self.env.filters.update({'strftime': DateFormatter()}) + self.env.filters.update({"strftime": DateFormatter()}) # get custom Jinja filters from user settings - custom_filters = self.settings['JINJA_FILTERS'] + custom_filters = self.settings["JINJA_FILTERS"] self.env.filters.update(custom_filters) # get custom Jinja globals from user settings - custom_globals = self.settings['JINJA_GLOBALS'] + custom_globals = self.settings["JINJA_GLOBALS"] self.env.globals.update(custom_globals) # get custom Jinja tests from user settings - custom_tests = self.settings['JINJA_TESTS'] + custom_tests = self.settings["JINJA_TESTS"] + self.env.tests["plugin_enabled"] = partial( + plugin_enabled, plugin_list=self.settings["PLUGINS"] + ) self.env.tests.update(custom_tests) signals.generator_init.send(self) @@ -92,7 +116,7 @@ class Generator: templates ready to use with Jinja2. """ if name not in self._templates: - for ext in self.settings['TEMPLATE_EXTENSIONS']: + for ext in self.settings["TEMPLATE_EXTENSIONS"]: try: self._templates[name] = self.env.get_template(name + ext) break @@ -101,9 +125,12 @@ class Generator: if name not in self._templates: raise PelicanTemplateNotFound( - '[templates] unable to load {}[{}] from {}'.format( - name, ', '.join(self.settings['TEMPLATE_EXTENSIONS']), - self._templates_path)) + "[templates] unable to load {}[{}] from {}".format( + name, + ", ".join(self.settings["TEMPLATE_EXTENSIONS"]), + self._templates_path, + ) + ) return self._templates[name] @@ -119,7 +146,7 @@ class Generator: basename = os.path.basename(path) # check IGNORE_FILES - ignores = self.settings['IGNORE_FILES'] + ignores = self.settings["IGNORE_FILES"] if any(fnmatch.fnmatch(basename, ignore) for ignore in ignores): return False @@ -148,20 +175,21 @@ class Generator: exclusions_by_dirpath.setdefault(parent_path, set()).add(subdir) files = set() - ignores = self.settings['IGNORE_FILES'] + ignores = self.settings["IGNORE_FILES"] for path in paths: # careful: os.path.join() will add a slash when path == ''. root = os.path.join(self.path, path) if path else self.path if os.path.isdir(root): for dirpath, dirs, temp_files in os.walk( - root, topdown=True, followlinks=True): + root, topdown=True, followlinks=True + ): excl = exclusions_by_dirpath.get(dirpath, ()) # We copy the `dirs` list as we will modify it in the loop: for d in list(dirs): - if (d in excl or - any(fnmatch.fnmatch(d, ignore) - for ignore in ignores)): + if d in excl or any( + fnmatch.fnmatch(d, ignore) for ignore in ignores + ): if d in dirs: dirs.remove(d) @@ -179,7 +207,7 @@ class Generator: Store a reference to its Content object, for url lookups later. """ location = content.get_relative_source_path() - key = 'static_content' if static else 'generated_content' + key = "static_content" if static else "generated_content" self.context[key][location] = content def _add_failed_source_path(self, path, static=False): @@ -187,7 +215,7 @@ class Generator: (For example, one that was missing mandatory metadata.) The path argument is expected to be relative to self.path. """ - key = 'static_content' if static else 'generated_content' + key = "static_content" if static else "generated_content" self.context[key][posixize_path(os.path.normpath(path))] = None def _is_potential_source_path(self, path, static=False): @@ -196,14 +224,14 @@ class Generator: before this method is called, even if they failed to process.) The path argument is expected to be relative to self.path. """ - key = 'static_content' if static else 'generated_content' - return (posixize_path(os.path.normpath(path)) in self.context[key]) + key = "static_content" if static else "generated_content" + return posixize_path(os.path.normpath(path)) in self.context[key] def add_static_links(self, content): """Add file links in content to context to be processed as Static content. """ - self.context['static_links'] |= content.get_static_links() + self.context["static_links"] |= content.get_static_links() def _update_context(self, items): """Update the context with the given items from the current processor. @@ -212,7 +240,7 @@ class Generator: """ for item in items: value = getattr(self, item) - if hasattr(value, 'items'): + if hasattr(value, "items"): value = list(value.items()) # py3k safeguard for iterators self.context[item] = value @@ -222,37 +250,35 @@ class Generator: class CachingGenerator(Generator, FileStampDataCacher): - '''Subclass of Generator and FileStampDataCacher classes + """Subclass of Generator and FileStampDataCacher classes enables content caching, either at the generator or reader level - ''' + """ def __init__(self, *args, **kwargs): - '''Initialize the generator, then set up caching + """Initialize the generator, then set up caching note the multiple inheritance structure - ''' + """ cls_name = self.__class__.__name__ - Generator.__init__(self, *args, - readers_cache_name=(cls_name + '-Readers'), - **kwargs) + Generator.__init__( + self, *args, readers_cache_name=(cls_name + "-Readers"), **kwargs + ) - cache_this_level = \ - self.settings['CONTENT_CACHING_LAYER'] == 'generator' - caching_policy = cache_this_level and self.settings['CACHE_CONTENT'] - load_policy = cache_this_level and self.settings['LOAD_CONTENT_CACHE'] - FileStampDataCacher.__init__(self, self.settings, cls_name, - caching_policy, load_policy - ) + cache_this_level = self.settings["CONTENT_CACHING_LAYER"] == "generator" + caching_policy = cache_this_level and self.settings["CACHE_CONTENT"] + load_policy = cache_this_level and self.settings["LOAD_CONTENT_CACHE"] + FileStampDataCacher.__init__( + self, self.settings, cls_name, caching_policy, load_policy + ) def _get_file_stamp(self, filename): - '''Get filestamp for path relative to generator.path''' + """Get filestamp for path relative to generator.path""" filename = os.path.join(self.path, filename) return super()._get_file_stamp(filename) class _FileLoader(BaseLoader): - def __init__(self, path, basedir): self.path = path self.fullpath = os.path.join(basedir, path) @@ -261,22 +287,21 @@ class _FileLoader(BaseLoader): if template != self.path or not os.path.exists(self.fullpath): raise TemplateNotFound(template) mtime = os.path.getmtime(self.fullpath) - with open(self.fullpath, encoding='utf-8') as f: + with open(self.fullpath, encoding="utf-8") as f: source = f.read() - return (source, self.fullpath, - lambda: mtime == os.path.getmtime(self.fullpath)) + return (source, self.fullpath, lambda: mtime == os.path.getmtime(self.fullpath)) class TemplatePagesGenerator(Generator): - def generate_output(self, writer): - for source, dest in self.settings['TEMPLATE_PAGES'].items(): + for source, dest in self.settings["TEMPLATE_PAGES"].items(): self.env.loader.loaders.insert(0, _FileLoader(source, self.path)) try: template = self.env.get_template(source) - rurls = self.settings['RELATIVE_URLS'] - writer.write_file(dest, template, self.context, rurls, - override_output=True, url='') + rurls = self.settings["RELATIVE_URLS"] + writer.write_file( + dest, template, self.context, rurls, override_output=True, url="" + ) finally: del self.env.loader.loaders[0] @@ -287,15 +312,16 @@ class ArticlesGenerator(CachingGenerator): def __init__(self, *args, **kwargs): """initialize properties""" # Published, listed articles - self.articles = [] # only articles in default language + self.articles = [] # only articles in default language self.translations = [] # Published, unlisted articles self.hidden_articles = [] self.hidden_translations = [] # Draft articles - self.drafts = [] # only drafts in default language + self.drafts = [] # only drafts in default language self.drafts_translations = [] self.dates = {} + self.period_archives = defaultdict(list) self.tags = defaultdict(list) self.categories = defaultdict(list) self.related_posts = [] @@ -306,300 +332,304 @@ class ArticlesGenerator(CachingGenerator): def generate_feeds(self, writer): """Generate the feeds from the current context, and output files.""" - if self.settings.get('FEED_ATOM'): + if self.settings.get("FEED_ATOM"): writer.write_feed( self.articles, self.context, - self.settings['FEED_ATOM'], - self.settings.get('FEED_ATOM_URL', self.settings['FEED_ATOM']) - ) + self.settings["FEED_ATOM"], + self.settings.get("FEED_ATOM_URL", self.settings["FEED_ATOM"]), + ) - if self.settings.get('FEED_RSS'): + if self.settings.get("FEED_RSS"): writer.write_feed( self.articles, self.context, - self.settings['FEED_RSS'], - self.settings.get('FEED_RSS_URL', self.settings['FEED_RSS']), - feed_type='rss' - ) + self.settings["FEED_RSS"], + self.settings.get("FEED_RSS_URL", self.settings["FEED_RSS"]), + feed_type="rss", + ) - if (self.settings.get('FEED_ALL_ATOM') or - self.settings.get('FEED_ALL_RSS')): + if self.settings.get("FEED_ALL_ATOM") or self.settings.get("FEED_ALL_RSS"): all_articles = list(self.articles) for article in self.articles: all_articles.extend(article.translations) - order_content(all_articles, - order_by=self.settings['ARTICLE_ORDER_BY']) + order_content(all_articles, order_by=self.settings["ARTICLE_ORDER_BY"]) - if self.settings.get('FEED_ALL_ATOM'): + if self.settings.get("FEED_ALL_ATOM"): writer.write_feed( all_articles, self.context, - self.settings['FEED_ALL_ATOM'], - self.settings.get('FEED_ALL_ATOM_URL', - self.settings['FEED_ALL_ATOM']) - ) + self.settings["FEED_ALL_ATOM"], + self.settings.get( + "FEED_ALL_ATOM_URL", self.settings["FEED_ALL_ATOM"] + ), + ) - if self.settings.get('FEED_ALL_RSS'): + if self.settings.get("FEED_ALL_RSS"): writer.write_feed( all_articles, self.context, - self.settings['FEED_ALL_RSS'], - self.settings.get('FEED_ALL_RSS_URL', - self.settings['FEED_ALL_RSS']), - feed_type='rss' - ) + self.settings["FEED_ALL_RSS"], + self.settings.get( + "FEED_ALL_RSS_URL", self.settings["FEED_ALL_RSS"] + ), + feed_type="rss", + ) for cat, arts in self.categories: - if self.settings.get('CATEGORY_FEED_ATOM'): + if self.settings.get("CATEGORY_FEED_ATOM"): writer.write_feed( arts, self.context, - self.settings['CATEGORY_FEED_ATOM'].format(slug=cat.slug), + str(self.settings["CATEGORY_FEED_ATOM"]).format(slug=cat.slug), self.settings.get( - 'CATEGORY_FEED_ATOM_URL', - self.settings['CATEGORY_FEED_ATOM']).format( - slug=cat.slug - ), - feed_title=cat.name - ) - - if self.settings.get('CATEGORY_FEED_RSS'): - writer.write_feed( - arts, - self.context, - self.settings['CATEGORY_FEED_RSS'].format(slug=cat.slug), - self.settings.get( - 'CATEGORY_FEED_RSS_URL', - self.settings['CATEGORY_FEED_RSS']).format( - slug=cat.slug - ), + "CATEGORY_FEED_ATOM_URL", + str(self.settings["CATEGORY_FEED_ATOM"]).format(slug=cat.slug), + ), feed_title=cat.name, - feed_type='rss' - ) + ) + + if self.settings.get("CATEGORY_FEED_RSS"): + writer.write_feed( + arts, + self.context, + 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), + ), + feed_title=cat.name, + feed_type="rss", + ) for auth, arts in self.authors: - if self.settings.get('AUTHOR_FEED_ATOM'): + if self.settings.get("AUTHOR_FEED_ATOM"): writer.write_feed( arts, self.context, - self.settings['AUTHOR_FEED_ATOM'].format(slug=auth.slug), + str(self.settings["AUTHOR_FEED_ATOM"]).format(slug=auth.slug), self.settings.get( - 'AUTHOR_FEED_ATOM_URL', - self.settings['AUTHOR_FEED_ATOM'] - ).format(slug=auth.slug), - feed_title=auth.name - ) - - if self.settings.get('AUTHOR_FEED_RSS'): - writer.write_feed( - arts, - self.context, - self.settings['AUTHOR_FEED_RSS'].format(slug=auth.slug), - self.settings.get( - 'AUTHOR_FEED_RSS_URL', - self.settings['AUTHOR_FEED_RSS'] - ).format(slug=auth.slug), + "AUTHOR_FEED_ATOM_URL", + str(self.settings["AUTHOR_FEED_ATOM"]).format(slug=auth.slug), + ), feed_title=auth.name, - feed_type='rss' + ) + + if self.settings.get("AUTHOR_FEED_RSS"): + writer.write_feed( + arts, + self.context, + 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), + ), + feed_title=auth.name, + feed_type="rss", + ) + + if self.settings.get("TAG_FEED_ATOM") or self.settings.get("TAG_FEED_RSS"): + for tag, arts in self.tags.items(): + if self.settings.get("TAG_FEED_ATOM"): + writer.write_feed( + arts, + self.context, + 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), + ), + feed_title=tag.name, ) - if (self.settings.get('TAG_FEED_ATOM') or - self.settings.get('TAG_FEED_RSS')): - for tag, arts in self.tags.items(): - if self.settings.get('TAG_FEED_ATOM'): + if self.settings.get("TAG_FEED_RSS"): writer.write_feed( arts, self.context, - self.settings['TAG_FEED_ATOM'].format(slug=tag.slug), + str(self.settings["TAG_FEED_RSS"]).format(slug=tag.slug), self.settings.get( - 'TAG_FEED_ATOM_URL', - self.settings['TAG_FEED_ATOM'] - ).format(slug=tag.slug), - feed_title=tag.name - ) - - if self.settings.get('TAG_FEED_RSS'): - writer.write_feed( - arts, - self.context, - self.settings['TAG_FEED_RSS'].format(slug=tag.slug), - self.settings.get( - 'TAG_FEED_RSS_URL', - self.settings['TAG_FEED_RSS'] - ).format(slug=tag.slug), + "TAG_FEED_RSS_URL", + str(self.settings["TAG_FEED_RSS"]).format(slug=tag.slug), + ), feed_title=tag.name, - feed_type='rss' - ) + feed_type="rss", + ) - if (self.settings.get('TRANSLATION_FEED_ATOM') or - self.settings.get('TRANSLATION_FEED_RSS')): + if self.settings.get("TRANSLATION_FEED_ATOM") or self.settings.get( + "TRANSLATION_FEED_RSS" + ): translations_feeds = defaultdict(list) for article in chain(self.articles, self.translations): translations_feeds[article.lang].append(article) for lang, items in translations_feeds.items(): - items = order_content( - items, order_by=self.settings['ARTICLE_ORDER_BY']) - if self.settings.get('TRANSLATION_FEED_ATOM'): + items = order_content(items, order_by=self.settings["ARTICLE_ORDER_BY"]) + if self.settings.get("TRANSLATION_FEED_ATOM"): writer.write_feed( items, self.context, - self.settings['TRANSLATION_FEED_ATOM'] - .format(lang=lang), + str(self.settings["TRANSLATION_FEED_ATOM"]).format(lang=lang), self.settings.get( - 'TRANSLATION_FEED_ATOM_URL', - self.settings['TRANSLATION_FEED_ATOM'] - ).format(lang=lang), - ) - if self.settings.get('TRANSLATION_FEED_RSS'): + "TRANSLATION_FEED_ATOM_URL", + str(self.settings["TRANSLATION_FEED_ATOM"]).format( + lang=lang + ), + ), + ) + if self.settings.get("TRANSLATION_FEED_RSS"): writer.write_feed( items, self.context, - self.settings['TRANSLATION_FEED_RSS'] - .format(lang=lang), + str(self.settings["TRANSLATION_FEED_RSS"]).format(lang=lang), self.settings.get( - 'TRANSLATION_FEED_RSS_URL', - self.settings['TRANSLATION_FEED_RSS'] - ).format(lang=lang), - feed_type='rss' - ) + "TRANSLATION_FEED_RSS_URL", + str(self.settings["TRANSLATION_FEED_RSS"]), + ).format(lang=lang), + feed_type="rss", + ) def generate_articles(self, write): """Generate the articles.""" for article in chain( - self.translations, self.articles, - self.hidden_translations, self.hidden_articles + self.translations, + self.articles, + self.hidden_translations, + self.hidden_articles, ): signals.article_generator_write_article.send(self, content=article) - write(article.save_as, self.get_template(article.template), - self.context, article=article, category=article.category, - override_output=hasattr(article, 'override_save_as'), - url=article.url, blog=True) + write( + article.save_as, + self.get_template(article.template), + self.context, + article=article, + category=article.category, + override_output=hasattr(article, "override_save_as"), + url=article.url, + blog=True, + ) def generate_period_archives(self, write): """Generate per-year, per-month, and per-day archives.""" try: - template = self.get_template('period_archives') + template = self.get_template("period_archives") except PelicanTemplateNotFound: - template = self.get_template('archives') + template = self.get_template("archives") - period_save_as = { - 'year': self.settings['YEAR_ARCHIVE_SAVE_AS'], - 'month': self.settings['MONTH_ARCHIVE_SAVE_AS'], - 'day': self.settings['DAY_ARCHIVE_SAVE_AS'], - } - - period_url = { - 'year': self.settings['YEAR_ARCHIVE_URL'], - 'month': self.settings['MONTH_ARCHIVE_URL'], - 'day': self.settings['DAY_ARCHIVE_URL'], - } - - period_date_key = { - 'year': attrgetter('date.year'), - 'month': attrgetter('date.year', 'date.month'), - 'day': attrgetter('date.year', 'date.month', 'date.day') - } - - def _generate_period_archives(dates, key, save_as_fmt, url_fmt): - """Generate period archives from `dates`, grouped by - `key` and written to `save_as`. - """ - # `dates` is already sorted by date - for _period, group in groupby(dates, key=key): - archive = list(group) - articles = [a for a in self.articles if a in archive] - # arbitrarily grab the first date so that the usual - # format string syntax can be used for specifying the - # period archive dates - date = archive[0].date - save_as = save_as_fmt.format(date=date) - url = url_fmt.format(date=date) + for granularity in self.period_archives: + for period in self.period_archives[granularity]: context = self.context.copy() + context["period"] = period["period"] + context["period_num"] = period["period_num"] - if key == period_date_key['year']: - context["period"] = (_period,) - context["period_num"] = (_period,) - else: - month_name = calendar.month_name[_period[1]] - if key == period_date_key['month']: - context["period"] = (_period[0], - month_name) - else: - context["period"] = (_period[0], - month_name, - _period[2]) - context["period_num"] = tuple(_period) - - write(save_as, template, context, articles=articles, - dates=archive, template_name='period_archives', - blog=True, url=url, all_articles=self.articles) - - for period in 'year', 'month', 'day': - save_as = period_save_as[period] - url = period_url[period] - if save_as: - key = period_date_key[period] - _generate_period_archives(self.dates, key, save_as, url) + write( + period["save_as"], + template, + context, + articles=period["articles"], + dates=period["dates"], + template_name="period_archives", + blog=True, + url=period["url"], + all_articles=self.articles, + ) def generate_direct_templates(self, write): """Generate direct templates pages""" - for template in self.settings['DIRECT_TEMPLATES']: - save_as = self.settings.get("%s_SAVE_AS" % template.upper(), - '%s.html' % template) - url = self.settings.get("%s_URL" % template.upper(), - '%s.html' % template) + for template in self.settings["DIRECT_TEMPLATES"]: + save_as = self.settings.get( + "%s_SAVE_AS" % template.upper(), "%s.html" % template + ) + url = self.settings.get("%s_URL" % template.upper(), "%s.html" % template) if not save_as: continue - write(save_as, self.get_template(template), self.context, - articles=self.articles, dates=self.dates, blog=True, - template_name=template, - page_name=os.path.splitext(save_as)[0], url=url) + write( + save_as, + self.get_template(template), + self.context, + articles=self.articles, + dates=self.dates, + blog=True, + template_name=template, + page_name=os.path.splitext(save_as)[0], + url=url, + ) def generate_tags(self, write): """Generate Tags pages.""" - tag_template = self.get_template('tag') + tag_template = self.get_template("tag") for tag, articles in self.tags.items(): dates = [article for article in self.dates if article in articles] - write(tag.save_as, tag_template, self.context, tag=tag, - url=tag.url, articles=articles, dates=dates, - template_name='tag', blog=True, page_name=tag.page_name, - all_articles=self.articles) + write( + tag.save_as, + tag_template, + self.context, + tag=tag, + url=tag.url, + articles=articles, + dates=dates, + template_name="tag", + blog=True, + page_name=tag.page_name, + all_articles=self.articles, + ) def generate_categories(self, write): """Generate category pages.""" - category_template = self.get_template('category') + category_template = self.get_template("category") for cat, articles in self.categories: dates = [article for article in self.dates if article in articles] - write(cat.save_as, category_template, self.context, url=cat.url, - category=cat, articles=articles, dates=dates, - template_name='category', blog=True, page_name=cat.page_name, - all_articles=self.articles) + write( + cat.save_as, + category_template, + self.context, + url=cat.url, + category=cat, + articles=articles, + dates=dates, + template_name="category", + blog=True, + page_name=cat.page_name, + all_articles=self.articles, + ) def generate_authors(self, write): """Generate Author pages.""" - author_template = self.get_template('author') + author_template = self.get_template("author") for aut, articles in self.authors: dates = [article for article in self.dates if article in articles] - write(aut.save_as, author_template, self.context, - url=aut.url, author=aut, articles=articles, dates=dates, - template_name='author', blog=True, - page_name=aut.page_name, all_articles=self.articles) + write( + aut.save_as, + author_template, + self.context, + url=aut.url, + author=aut, + articles=articles, + dates=dates, + template_name="author", + blog=True, + page_name=aut.page_name, + all_articles=self.articles, + ) def generate_drafts(self, write): """Generate drafts pages.""" for draft in chain(self.drafts_translations, self.drafts): - write(draft.save_as, self.get_template(draft.template), - self.context, article=draft, category=draft.category, - override_output=hasattr(draft, 'override_save_as'), - blog=True, all_articles=self.articles, url=draft.url) + write( + draft.save_as, + self.get_template(draft.template), + self.context, + article=draft, + category=draft.category, + override_output=hasattr(draft, "override_save_as"), + blog=True, + all_articles=self.articles, + url=draft.url, + ) def generate_pages(self, writer): """Generate the pages on the disk""" - write = partial(writer.write_file, - relative_urls=self.settings['RELATIVE_URLS']) + write = partial(writer.write_file, relative_urls=self.settings["RELATIVE_URLS"]) # to minimize the number of relative path stuff modification # in writer, articles pass first @@ -620,22 +650,28 @@ class ArticlesGenerator(CachingGenerator): all_drafts = [] hidden_articles = [] for f in self.get_files( - self.settings['ARTICLE_PATHS'], - exclude=self.settings['ARTICLE_EXCLUDES']): + self.settings["ARTICLE_PATHS"], exclude=self.settings["ARTICLE_EXCLUDES"] + ): article = self.get_cached_data(f, None) if article is None: try: article = self.readers.read_file( - base_path=self.path, path=f, content_class=Article, + base_path=self.path, + path=f, + content_class=Article, context=self.context, preread_signal=signals.article_generator_preread, preread_sender=self, context_signal=signals.article_generator_context, - context_sender=self) + context_sender=self, + ) except Exception as e: logger.error( - 'Could not process %s\n%s', f, e, - exc_info=self.settings.get('DEBUG', False)) + "Could not process %s\n%s", + f, + e, + exc_info=self.settings.get("DEBUG", False), + ) self._add_failed_source_path(f) continue @@ -657,8 +693,9 @@ class ArticlesGenerator(CachingGenerator): def _process(arts): origs, translations = process_translations( - arts, translation_id=self.settings['ARTICLE_TRANSLATION_ID']) - origs = order_content(origs, self.settings['ARTICLE_ORDER_BY']) + arts, translation_id=self.settings["ARTICLE_TRANSLATION_ID"] + ) + origs = order_content(origs, self.settings["ARTICLE_ORDER_BY"]) return origs, translations self.articles, self.translations = _process(all_articles) @@ -671,47 +708,131 @@ class ArticlesGenerator(CachingGenerator): # only main articles are listed in categories and tags # not translations or hidden articles self.categories[article.category].append(article) - if hasattr(article, 'tags'): + if hasattr(article, "tags"): for tag in article.tags: self.tags[tag].append(article) - for author in getattr(article, 'authors', []): + for author in getattr(article, "authors", []): self.authors[author].append(article) self.dates = list(self.articles) - self.dates.sort(key=attrgetter('date'), - reverse=self.context['NEWEST_FIRST_ARCHIVES']) + self.dates.sort( + key=attrgetter("date"), reverse=self.context["NEWEST_FIRST_ARCHIVES"] + ) + + self.period_archives = self._build_period_archives( + self.dates, self.articles, self.settings + ) # and generate the output :) # order the categories per name self.categories = list(self.categories.items()) - self.categories.sort( - reverse=self.settings['REVERSE_CATEGORY_ORDER']) + self.categories.sort(reverse=self.settings["REVERSE_CATEGORY_ORDER"]) self.authors = list(self.authors.items()) self.authors.sort() - self._update_context(( - 'articles', 'drafts', 'hidden_articles', - 'dates', 'tags', 'categories', - 'authors', 'related_posts')) + self._update_context( + ( + "articles", + "drafts", + "hidden_articles", + "dates", + "tags", + "categories", + "authors", + "related_posts", + ) + ) + # _update_context flattens dicts, which should not happen to + # period_archives, so we update the context directly for it: + self.context["period_archives"] = self.period_archives self.save_cache() self.readers.save_cache() signals.article_generator_finalized.send(self) + def _build_period_archives(self, sorted_articles, articles, settings): + """ + Compute the groupings of articles, with related attributes, for + per-year, per-month, and per-day archives. + """ + + period_archives = defaultdict(list) + + period_archives_settings = { + "year": { + "save_as": settings["YEAR_ARCHIVE_SAVE_AS"], + "url": settings["YEAR_ARCHIVE_URL"], + }, + "month": { + "save_as": settings["MONTH_ARCHIVE_SAVE_AS"], + "url": settings["MONTH_ARCHIVE_URL"], + }, + "day": { + "save_as": settings["DAY_ARCHIVE_SAVE_AS"], + "url": settings["DAY_ARCHIVE_URL"], + }, + } + + granularity_key_func = { + "year": attrgetter("date.year"), + "month": attrgetter("date.year", "date.month"), + "day": attrgetter("date.year", "date.month", "date.day"), + } + + for granularity in "year", "month", "day": + save_as_fmt = period_archives_settings[granularity]["save_as"] + url_fmt = period_archives_settings[granularity]["url"] + key_func = granularity_key_func[granularity] + + if not save_as_fmt: + # the archives for this period granularity are not needed + continue + + for period, group in groupby(sorted_articles, key=key_func): + archive = {} + + dates = list(group) + archive["dates"] = dates + archive["articles"] = [a for a in articles if a in dates] + + # use the first date to specify the period archive URL + # and save_as; the specific date used does not matter as + # they all belong to the same period + d = dates[0].date + archive["save_as"] = save_as_fmt.format(date=d) + archive["url"] = url_fmt.format(date=d) + + if granularity == "year": + archive["period"] = (period,) + archive["period_num"] = (period,) + else: + month_name = calendar.month_name[period[1]] + if granularity == "month": + archive["period"] = (period[0], month_name) + else: + archive["period"] = (period[0], month_name, period[2]) + archive["period_num"] = tuple(period) + + period_archives[granularity].append(archive) + + return period_archives + def generate_output(self, writer): self.generate_feeds(writer) self.generate_pages(writer) signals.article_writer_finalized.send(self, writer=writer) def refresh_metadata_intersite_links(self): - for e in chain(self.articles, - self.translations, - self.drafts, - self.drafts_translations, - self.hidden_articles, - self.hidden_translations): - if hasattr(e, 'refresh_metadata_intersite_links'): + for e in chain( + self.articles, + self.translations, + self.drafts, + self.drafts_translations, + self.hidden_articles, + self.hidden_translations, + ): + if hasattr(e, "refresh_metadata_intersite_links"): e.refresh_metadata_intersite_links() @@ -733,22 +854,28 @@ class PagesGenerator(CachingGenerator): hidden_pages = [] draft_pages = [] for f in self.get_files( - self.settings['PAGE_PATHS'], - exclude=self.settings['PAGE_EXCLUDES']): + self.settings["PAGE_PATHS"], exclude=self.settings["PAGE_EXCLUDES"] + ): page = self.get_cached_data(f, None) if page is None: try: page = self.readers.read_file( - base_path=self.path, path=f, content_class=Page, + base_path=self.path, + path=f, + content_class=Page, context=self.context, preread_signal=signals.page_generator_preread, preread_sender=self, context_signal=signals.page_generator_context, - context_sender=self) + context_sender=self, + ) except Exception as e: logger.error( - 'Could not process %s\n%s', f, e, - exc_info=self.settings.get('DEBUG', False)) + "Could not process %s\n%s", + f, + e, + exc_info=self.settings.get("DEBUG", False), + ) self._add_failed_source_path(f) continue @@ -769,40 +896,51 @@ class PagesGenerator(CachingGenerator): def _process(pages): origs, translations = process_translations( - pages, translation_id=self.settings['PAGE_TRANSLATION_ID']) - origs = order_content(origs, self.settings['PAGE_ORDER_BY']) + pages, translation_id=self.settings["PAGE_TRANSLATION_ID"] + ) + origs = order_content(origs, self.settings["PAGE_ORDER_BY"]) return origs, translations self.pages, self.translations = _process(all_pages) self.hidden_pages, self.hidden_translations = _process(hidden_pages) self.draft_pages, self.draft_translations = _process(draft_pages) - self._update_context(('pages', 'hidden_pages', 'draft_pages')) + self._update_context(("pages", "hidden_pages", "draft_pages")) self.save_cache() self.readers.save_cache() signals.page_generator_finalized.send(self) def generate_output(self, writer): - for page in chain(self.translations, self.pages, - self.hidden_translations, self.hidden_pages, - self.draft_translations, self.draft_pages): + for page in chain( + self.translations, + self.pages, + self.hidden_translations, + self.hidden_pages, + self.draft_translations, + self.draft_pages, + ): signals.page_generator_write_page.send(self, content=page) writer.write_file( - page.save_as, self.get_template(page.template), - self.context, page=page, - relative_urls=self.settings['RELATIVE_URLS'], - override_output=hasattr(page, 'override_save_as'), - url=page.url) + page.save_as, + self.get_template(page.template), + self.context, + page=page, + relative_urls=self.settings["RELATIVE_URLS"], + override_output=hasattr(page, "override_save_as"), + url=page.url, + ) signals.page_writer_finalized.send(self, writer=writer) def refresh_metadata_intersite_links(self): - for e in chain(self.pages, - self.hidden_pages, - self.hidden_translations, - self.draft_pages, - self.draft_translations): - if hasattr(e, 'refresh_metadata_intersite_links'): + for e in chain( + self.pages, + self.hidden_pages, + self.hidden_translations, + self.draft_pages, + self.draft_translations, + ): + if hasattr(e, "refresh_metadata_intersite_links"): e.refresh_metadata_intersite_links() @@ -817,71 +955,82 @@ class StaticGenerator(Generator): def generate_context(self): self.staticfiles = [] - linked_files = set(self.context['static_links']) - found_files = self.get_files(self.settings['STATIC_PATHS'], - exclude=self.settings['STATIC_EXCLUDES'], - extensions=False) + linked_files = set(self.context["static_links"]) + found_files = self.get_files( + self.settings["STATIC_PATHS"], + exclude=self.settings["STATIC_EXCLUDES"], + extensions=False, + ) for f in linked_files | found_files: - # skip content source files unless the user explicitly wants them - if self.settings['STATIC_EXCLUDE_SOURCES']: + if self.settings["STATIC_EXCLUDE_SOURCES"]: if self._is_potential_source_path(f): continue static = self.readers.read_file( - base_path=self.path, path=f, content_class=Static, - fmt='static', context=self.context, + base_path=self.path, + path=f, + content_class=Static, + fmt="static", + context=self.context, preread_signal=signals.static_generator_preread, preread_sender=self, context_signal=signals.static_generator_context, - context_sender=self) + context_sender=self, + ) self.staticfiles.append(static) self.add_source_path(static, static=True) - self._update_context(('staticfiles',)) + self._update_context(("staticfiles",)) signals.static_generator_finalized.send(self) def generate_output(self, writer): - self._copy_paths(self.settings['THEME_STATIC_PATHS'], self.theme, - self.settings['THEME_STATIC_DIR'], self.output_path, - os.curdir) - for sc in self.context['staticfiles']: + self._copy_paths( + self.settings["THEME_STATIC_PATHS"], + self.theme, + self.settings["THEME_STATIC_DIR"], + self.output_path, + os.curdir, + ) + for sc in self.context["staticfiles"]: if self._file_update_required(sc): self._link_or_copy_staticfile(sc) else: - logger.debug('%s is up to date, not copying', sc.source_path) + logger.debug("%s is up to date, not copying", sc.source_path) - def _copy_paths(self, paths, source, destination, output_path, - final_path=None): + def _copy_paths(self, paths, source, destination, output_path, final_path=None): """Copy all the paths from source to destination""" for path in paths: source_path = os.path.join(source, path) if final_path: if os.path.isfile(source_path): - destination_path = os.path.join(output_path, destination, - final_path, - os.path.basename(path)) + destination_path = os.path.join( + output_path, destination, final_path, os.path.basename(path) + ) else: - destination_path = os.path.join(output_path, destination, - final_path) + destination_path = os.path.join( + output_path, destination, final_path + ) else: destination_path = os.path.join(output_path, destination, path) - copy(source_path, destination_path, - self.settings['IGNORE_FILES']) + copy(source_path, destination_path, self.settings["IGNORE_FILES"]) def _file_update_required(self, staticfile): source_path = os.path.join(self.path, staticfile.source_path) save_as = os.path.join(self.output_path, staticfile.save_as) if not os.path.exists(save_as): return True - elif (self.settings['STATIC_CREATE_LINKS'] and - os.path.samefile(source_path, save_as)): + elif self.settings["STATIC_CREATE_LINKS"] and os.path.samefile( + source_path, save_as + ): return False - elif (self.settings['STATIC_CREATE_LINKS'] and - os.path.realpath(save_as) == source_path): + elif ( + self.settings["STATIC_CREATE_LINKS"] + and os.path.realpath(save_as) == source_path + ): return False - elif not self.settings['STATIC_CHECK_IF_MODIFIED']: + elif not self.settings["STATIC_CHECK_IF_MODIFIED"]: return True else: return self._source_is_newer(staticfile) @@ -894,7 +1043,7 @@ class StaticGenerator(Generator): return s_mtime - d_mtime > 0.000001 def _link_or_copy_staticfile(self, sc): - if self.settings['STATIC_CREATE_LINKS']: + if self.settings["STATIC_CREATE_LINKS"]: self._link_staticfile(sc) else: self._copy_staticfile(sc) @@ -904,7 +1053,7 @@ class StaticGenerator(Generator): save_as = os.path.join(self.output_path, sc.save_as) self._mkdir(os.path.dirname(save_as)) copy(source_path, save_as) - logger.info('Copying %s to %s', sc.source_path, sc.save_as) + logger.info("Copying %s to %s", sc.source_path, sc.save_as) def _link_staticfile(self, sc): source_path = os.path.join(self.path, sc.source_path) @@ -913,7 +1062,7 @@ class StaticGenerator(Generator): try: if os.path.lexists(save_as): os.unlink(save_as) - logger.info('Linking %s and %s', sc.source_path, sc.save_as) + logger.info("Linking %s and %s", sc.source_path, sc.save_as) if self.fallback_to_symlinks: os.symlink(source_path, save_as) else: @@ -921,9 +1070,8 @@ 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) else: @@ -936,19 +1084,17 @@ class StaticGenerator(Generator): class SourceFileGenerator(Generator): - def generate_context(self): - self.output_extension = self.settings['OUTPUT_SOURCES_EXTENSION'] + self.output_extension = self.settings["OUTPUT_SOURCES_EXTENSION"] def _create_source(self, obj): output_path, _ = os.path.splitext(obj.save_as) - dest = os.path.join(self.output_path, - output_path + self.output_extension) + dest = os.path.join(self.output_path, output_path + self.output_extension) copy(obj.source_path, dest) def generate_output(self, writer=None): - logger.info('Generating source files...') - for obj in chain(self.context['articles'], self.context['pages']): + logger.info("Generating source files...") + for obj in chain(self.context["articles"], self.context["pages"]): self._create_source(obj) for obj_trans in obj.translations: self._create_source(obj_trans) diff --git a/pelican/log.py b/pelican/log.py index be176ea8..0d2b6a3f 100644 --- a/pelican/log.py +++ b/pelican/log.py @@ -4,9 +4,7 @@ from collections import defaultdict from rich.console import Console from rich.logging import RichHandler -__all__ = [ - 'init' -] +__all__ = ["init"] console = Console() @@ -34,8 +32,8 @@ class LimitFilter(logging.Filter): return True # extract group - group = record.__dict__.get('limit_msg', None) - group_args = record.__dict__.get('limit_args', ()) + group = record.__dict__.get("limit_msg", None) + group_args = record.__dict__.get("limit_args", ()) # ignore record if it was already raised message_key = (record.levelno, record.getMessage()) @@ -50,7 +48,7 @@ class LimitFilter(logging.Filter): if logger_level > logging.DEBUG: template_key = (record.levelno, record.msg) message_key = (record.levelno, record.getMessage()) - if (template_key in self._ignore or message_key in self._ignore): + if template_key in self._ignore or message_key in self._ignore: return False # check if we went over threshold @@ -90,12 +88,12 @@ class FatalLogger(LimitLogger): def warning(self, *args, **kwargs): super().warning(*args, **kwargs) if FatalLogger.warnings_fatal: - raise RuntimeError('Warning encountered') + raise RuntimeError("Warning encountered") def error(self, *args, **kwargs): super().error(*args, **kwargs) if FatalLogger.errors_fatal: - raise RuntimeError('Error encountered') + raise RuntimeError("Error encountered") logging.setLoggerClass(FatalLogger) @@ -103,17 +101,19 @@ logging.setLoggerClass(FatalLogger) logging.getLogger().__class__ = FatalLogger -def init(level=None, fatal='', handler=RichHandler(console=console), name=None, - logs_dedup_min_level=None): - FatalLogger.warnings_fatal = fatal.startswith('warning') +def init( + level=None, + fatal="", + handler=RichHandler(console=console), + name=None, + logs_dedup_min_level=None, +): + FatalLogger.warnings_fatal = fatal.startswith("warning") FatalLogger.errors_fatal = bool(fatal) 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] ) logger = logging.getLogger(name) @@ -126,17 +126,18 @@ def init(level=None, fatal='', handler=RichHandler(console=console), name=None, def log_warnings(): import warnings + logging.captureWarnings(True) warnings.simplefilter("default", DeprecationWarning) - init(logging.DEBUG, name='py.warnings') + init(logging.DEBUG, name="py.warnings") -if __name__ == '__main__': +if __name__ == "__main__": init(level=logging.DEBUG, name=__name__) root_logger = logging.getLogger(__name__) - root_logger.debug('debug') - root_logger.info('info') - root_logger.warning('warning') - root_logger.error('error') - root_logger.critical('critical') + root_logger.debug("debug") + root_logger.info("info") + root_logger.warning("warning") + root_logger.error("error") + root_logger.critical("critical") diff --git a/pelican/paginator.py b/pelican/paginator.py index 4231e67b..e1d50881 100644 --- a/pelican/paginator.py +++ b/pelican/paginator.py @@ -6,8 +6,8 @@ from math import ceil logger = logging.getLogger(__name__) PaginationRule = namedtuple( - 'PaginationRule', - 'min_page URL SAVE_AS', + "PaginationRule", + "min_page URL SAVE_AS", ) @@ -19,7 +19,7 @@ class Paginator: self.settings = settings if per_page: self.per_page = per_page - self.orphans = settings['DEFAULT_ORPHANS'] + self.orphans = settings["DEFAULT_ORPHANS"] else: self.per_page = len(object_list) self.orphans = 0 @@ -32,14 +32,21 @@ class Paginator: top = bottom + self.per_page if top + self.orphans >= self.count: top = self.count - return Page(self.name, self.url, self.object_list[bottom:top], number, - self, self.settings) + return Page( + self.name, + self.url, + self.object_list[bottom:top], + number, + self, + self.settings, + ) def _get_count(self): "Returns the total number of objects, across all pages." if self._count is None: self._count = len(self.object_list) return self._count + count = property(_get_count) def _get_num_pages(self): @@ -48,6 +55,7 @@ class Paginator: hits = max(1, self.count - self.orphans) self._num_pages = int(ceil(hits / (float(self.per_page) or 1))) return self._num_pages + num_pages = property(_get_num_pages) def _get_page_range(self): @@ -56,6 +64,7 @@ class Paginator: a template for loop. """ return list(range(1, self.num_pages + 1)) + page_range = property(_get_page_range) @@ -64,7 +73,7 @@ class Page: self.full_name = name self.name, self.extension = os.path.splitext(name) dn, fn = os.path.split(name) - self.base_name = dn if fn in ('index.htm', 'index.html') else self.name + self.base_name = dn if fn in ("index.htm", "index.html") else self.name self.base_url = url self.object_list = object_list self.number = number @@ -72,7 +81,7 @@ class Page: self.settings = settings def __repr__(self): - return ''.format(self.number, self.paginator.num_pages) + return f"" def has_next(self): return self.number < self.paginator.num_pages @@ -117,7 +126,7 @@ class Page: rule = None # find the last matching pagination rule - for p in self.settings['PAGINATION_PATTERNS']: + for p in self.settings["PAGINATION_PATTERNS"]: if p.min_page == -1: if not self.has_next(): rule = p @@ -127,22 +136,22 @@ class Page: rule = p if not rule: - return '' + return "" prop_value = getattr(rule, key) if not isinstance(prop_value, str): - logger.warning('%s is set to %s', key, prop_value) + logger.warning("%s is set to %s", key, prop_value) return prop_value # URL or SAVE_AS is a string, format it with a controlled context context = { - 'save_as': self.full_name, - 'url': self.base_url, - 'name': self.name, - 'base_name': self.base_name, - 'extension': self.extension, - 'number': self.number, + "save_as": self.full_name, + "url": self.base_url, + "name": self.name, + "base_name": self.base_name, + "extension": self.extension, + "number": self.number, } ret = prop_value.format(**context) @@ -155,9 +164,9 @@ class Page: # changed to lstrip() because that would remove all leading slashes and # thus make the workaround impossible. See # test_custom_pagination_pattern() for a verification of this. - if ret.startswith('/'): + if ret.startswith("/"): ret = ret[1:] return ret - url = property(functools.partial(_from_settings, key='URL')) - save_as = property(functools.partial(_from_settings, key='SAVE_AS')) + url = property(functools.partial(_from_settings, key="URL")) + save_as = property(functools.partial(_from_settings, key="SAVE_AS")) diff --git a/pelican/plugins/_utils.py b/pelican/plugins/_utils.py index 87877b08..805ed049 100644 --- a/pelican/plugins/_utils.py +++ b/pelican/plugins/_utils.py @@ -24,26 +24,42 @@ def get_namespace_plugins(ns_pkg=None): return { name: importlib.import_module(name) - for finder, name, ispkg - in iter_namespace(ns_pkg) + for finder, name, ispkg in iter_namespace(ns_pkg) if ispkg } def list_plugins(ns_pkg=None): from pelican.log import init as init_logging + init_logging(logging.INFO) ns_plugins = get_namespace_plugins(ns_pkg) if ns_plugins: - logger.info('Plugins found:\n' + '\n'.join(ns_plugins)) + logger.info("Plugins found:\n" + "\n".join(ns_plugins)) else: - logger.info('No plugins are installed') + logger.info("No plugins are installed") + + +def plugin_enabled(name, plugin_list=None): + if plugin_list is None or not plugin_list: + # no plugins are loaded + return False + + if name in plugin_list: + # search name as is + return True + + if f"pelican.plugins.{name}" in plugin_list: + # check if short name is a namespace plugin + return True + + return False def load_legacy_plugin(plugin, plugin_paths): - if '.' in plugin: + if "." in plugin: # it is in a package, try to resolve package first - package, _, _ = plugin.rpartition('.') + package, _, _ = plugin.rpartition(".") load_legacy_plugin(package, plugin_paths) # Try to find plugin in PLUGIN_PATHS @@ -52,7 +68,7 @@ def load_legacy_plugin(plugin, plugin_paths): # If failed, try to find it in normal importable locations spec = importlib.util.find_spec(plugin) if spec is None: - raise ImportError('Cannot import plugin `{}`'.format(plugin)) + raise ImportError(f"Cannot import plugin `{plugin}`") else: # Avoid loading the same plugin twice if spec.name in sys.modules: @@ -78,30 +94,28 @@ def load_legacy_plugin(plugin, plugin_paths): def load_plugins(settings): - logger.debug('Finding namespace plugins') + logger.debug("Finding namespace plugins") namespace_plugins = get_namespace_plugins() if namespace_plugins: - logger.debug('Namespace plugins found:\n' + - '\n'.join(namespace_plugins)) + logger.debug("Namespace plugins found:\n" + "\n".join(namespace_plugins)) plugins = [] - if settings.get('PLUGINS') is not None: - for plugin in settings['PLUGINS']: + if settings.get("PLUGINS") is not None: + for plugin in settings["PLUGINS"]: if isinstance(plugin, str): - logger.debug('Loading plugin `%s`', plugin) + logger.debug("Loading plugin `%s`", plugin) # try to find in namespace plugins if plugin in namespace_plugins: plugin = namespace_plugins[plugin] - elif 'pelican.plugins.{}'.format(plugin) in namespace_plugins: - plugin = namespace_plugins['pelican.plugins.{}'.format( - plugin)] + elif f"pelican.plugins.{plugin}" in namespace_plugins: + plugin = namespace_plugins[f"pelican.plugins.{plugin}"] # try to import it else: try: plugin = load_legacy_plugin( - plugin, - settings.get('PLUGIN_PATHS', [])) + plugin, settings.get("PLUGIN_PATHS", []) + ) except ImportError as e: - logger.error('Cannot load plugin `%s`\n%s', plugin, e) + logger.error("Cannot load plugin `%s`\n%s", plugin, e) continue plugins.append(plugin) else: diff --git a/pelican/plugins/signals.py b/pelican/plugins/signals.py index 4013360f..27177367 100644 --- a/pelican/plugins/signals.py +++ b/pelican/plugins/signals.py @@ -1,49 +1,53 @@ -from blinker import signal +from blinker import signal, Signal +from ordered_set import OrderedSet + +# Signals will call functions in the order of connection, i.e. plugin order +Signal.set_class = OrderedSet # Run-level signals: -initialized = signal('pelican_initialized') -get_generators = signal('get_generators') -all_generators_finalized = signal('all_generators_finalized') -get_writer = signal('get_writer') -finalized = signal('pelican_finalized') +initialized = signal("pelican_initialized") +get_generators = signal("get_generators") +all_generators_finalized = signal("all_generators_finalized") +get_writer = signal("get_writer") +finalized = signal("pelican_finalized") # Reader-level signals -readers_init = signal('readers_init') +readers_init = signal("readers_init") # Generator-level signals -generator_init = signal('generator_init') +generator_init = signal("generator_init") -article_generator_init = signal('article_generator_init') -article_generator_pretaxonomy = signal('article_generator_pretaxonomy') -article_generator_finalized = signal('article_generator_finalized') -article_generator_write_article = signal('article_generator_write_article') -article_writer_finalized = signal('article_writer_finalized') +article_generator_init = signal("article_generator_init") +article_generator_pretaxonomy = signal("article_generator_pretaxonomy") +article_generator_finalized = signal("article_generator_finalized") +article_generator_write_article = signal("article_generator_write_article") +article_writer_finalized = signal("article_writer_finalized") -page_generator_init = signal('page_generator_init') -page_generator_finalized = signal('page_generator_finalized') -page_generator_write_page = signal('page_generator_write_page') -page_writer_finalized = signal('page_writer_finalized') +page_generator_init = signal("page_generator_init") +page_generator_finalized = signal("page_generator_finalized") +page_generator_write_page = signal("page_generator_write_page") +page_writer_finalized = signal("page_writer_finalized") -static_generator_init = signal('static_generator_init') -static_generator_finalized = signal('static_generator_finalized') +static_generator_init = signal("static_generator_init") +static_generator_finalized = signal("static_generator_finalized") # Page-level signals -article_generator_preread = signal('article_generator_preread') -article_generator_context = signal('article_generator_context') +article_generator_preread = signal("article_generator_preread") +article_generator_context = signal("article_generator_context") -page_generator_preread = signal('page_generator_preread') -page_generator_context = signal('page_generator_context') +page_generator_preread = signal("page_generator_preread") +page_generator_context = signal("page_generator_context") -static_generator_preread = signal('static_generator_preread') -static_generator_context = signal('static_generator_context') +static_generator_preread = signal("static_generator_preread") +static_generator_context = signal("static_generator_context") -content_object_init = signal('content_object_init') +content_object_init = signal("content_object_init") # Writers signals -content_written = signal('content_written') -feed_generated = signal('feed_generated') -feed_written = signal('feed_written') +content_written = signal("content_written") +feed_generated = signal("feed_generated") +feed_written = signal("feed_written") diff --git a/pelican/readers.py b/pelican/readers.py index 03c3cc20..60b9765a 100644 --- a/pelican/readers.py +++ b/pelican/readers.py @@ -31,33 +31,29 @@ except ImportError: _DISCARD = object() DUPLICATES_DEFINITIONS_ALLOWED = { - 'tags': False, - 'date': False, - 'modified': False, - 'status': False, - 'category': False, - 'author': False, - 'save_as': False, - 'url': False, - 'authors': False, - 'slug': False + "tags": False, + "date": False, + "modified": False, + "status": False, + "category": False, + "author": False, + "save_as": False, + "url": False, + "authors": False, + "slug": False, } METADATA_PROCESSORS = { - 'tags': lambda x, y: ([ - Tag(tag, y) - for tag in ensure_metadata_list(x) - ] or _DISCARD), - 'date': lambda x, y: get_date(x.replace('_', ' ')), - 'modified': lambda x, y: get_date(x), - 'status': lambda x, y: x.strip() or _DISCARD, - 'category': lambda x, y: _process_if_nonempty(Category, x, y), - 'author': lambda x, y: _process_if_nonempty(Author, x, y), - 'authors': lambda x, y: ([ - Author(author, y) - for author in ensure_metadata_list(x) - ] or _DISCARD), - 'slug': lambda x, y: x.strip() or _DISCARD, + "tags": lambda x, y: ([Tag(tag, y) for tag in ensure_metadata_list(x)] or _DISCARD), + "date": lambda x, y: get_date(x.replace("_", " ")), + "modified": lambda x, y: get_date(x), + "status": lambda x, y: x.strip() or _DISCARD, + "category": lambda x, y: _process_if_nonempty(Category, x, y), + "author": lambda x, y: _process_if_nonempty(Author, x, y), + "authors": lambda x, y: ( + [Author(author, y) for author in ensure_metadata_list(x)] or _DISCARD + ), + "slug": lambda x, y: x.strip() or _DISCARD, } logger = logging.getLogger(__name__) @@ -65,25 +61,23 @@ logger = logging.getLogger(__name__) def ensure_metadata_list(text): """Canonicalize the format of a list of authors or tags. This works - the same way as Docutils' "authors" field: if it's already a list, - those boundaries are preserved; otherwise, it must be a string; - if the string contains semicolons, it is split on semicolons; - otherwise, it is split on commas. This allows you to write - author lists in either "Jane Doe, John Doe" or "Doe, Jane; Doe, John" - format. + the same way as Docutils' "authors" field: if it's already a list, + those boundaries are preserved; otherwise, it must be a string; + if the string contains semicolons, it is split on semicolons; + otherwise, it is split on commas. This allows you to write + author lists in either "Jane Doe, John Doe" or "Doe, Jane; Doe, John" + format. - Regardless, all list items undergo .strip() before returning, and - empty items are discarded. + Regardless, all list items undergo .strip() before returning, and + empty items are discarded. """ if isinstance(text, str): - if ';' in text: - text = text.split(';') + if ";" in text: + text = text.split(";") else: - text = text.split(',') + text = text.split(",") - return list(OrderedDict.fromkeys( - [v for v in (w.strip() for w in text) if v] - )) + return list(OrderedDict.fromkeys([v for v in (w.strip() for w in text) if v])) def _process_if_nonempty(processor, name, settings): @@ -112,8 +106,9 @@ class BaseReader: Markdown). """ + enabled = True - file_extensions = ['static'] + file_extensions = ["static"] extensions = None def __init__(self, settings): @@ -132,13 +127,12 @@ class BaseReader: class _FieldBodyTranslator(HTMLTranslator): - def __init__(self, document): super().__init__(document) self.compact_p = None def astext(self): - return ''.join(self.body) + return "".join(self.body) def visit_field_body(self, node): pass @@ -154,27 +148,25 @@ def render_node_to_html(document, node, field_body_translator_class): class PelicanHTMLWriter(Writer): - def __init__(self): super().__init__() self.translator_class = PelicanHTMLTranslator class PelicanHTMLTranslator(HTMLTranslator): - def visit_abbreviation(self, node): attrs = {} - if node.hasattr('explanation'): - attrs['title'] = node['explanation'] - self.body.append(self.starttag(node, 'abbr', '', **attrs)) + if node.hasattr("explanation"): + attrs["title"] = node["explanation"] + self.body.append(self.starttag(node, "abbr", "", **attrs)) def depart_abbreviation(self, node): - self.body.append('') + self.body.append("") def visit_image(self, node): # set an empty alt if alt is not specified # avoids that alt is taken from src - node['alt'] = node.get('alt', '') + node["alt"] = node.get("alt", "") return HTMLTranslator.visit_image(self, node) @@ -194,7 +186,7 @@ class RstReader(BaseReader): """ enabled = bool(docutils) - file_extensions = ['rst'] + file_extensions = ["rst"] writer_class = PelicanHTMLWriter field_body_translator_class = _FieldBodyTranslator @@ -202,25 +194,28 @@ class RstReader(BaseReader): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - lang_code = self.settings.get('DEFAULT_LANG', 'en') + lang_code = self.settings.get("DEFAULT_LANG", "en") if get_docutils_lang(lang_code): self._language_code = lang_code else: - logger.warning("Docutils has no localization for '%s'." - " Using 'en' instead.", lang_code) - self._language_code = 'en' + logger.warning( + "Docutils has no localization for '%s'." " Using 'en' instead.", + lang_code, + ) + self._language_code = "en" def _parse_metadata(self, document, source_path): """Return the dict containing document metadata""" - formatted_fields = self.settings['FORMATTED_FIELDS'] + formatted_fields = self.settings["FORMATTED_FIELDS"] output = {} if document.first_child_matching_class(docutils.nodes.title) is None: logger.warning( - 'Document title missing in file %s: ' - 'Ensure exactly one top level section', - source_path) + "Document title missing in file %s: " + "Ensure exactly one top level section", + source_path, + ) try: # docutils 0.18.1+ @@ -231,16 +226,16 @@ class RstReader(BaseReader): for docinfo in nodes: for element in docinfo.children: - if element.tagname == 'field': # custom fields (e.g. summary) + if element.tagname == "field": # custom fields (e.g. summary) name_elem, body_elem = element.children name = name_elem.astext() if name.lower() in formatted_fields: value = render_node_to_html( - document, body_elem, - self.field_body_translator_class) + document, body_elem, self.field_body_translator_class + ) else: value = body_elem.astext() - elif element.tagname == 'authors': # author list + elif element.tagname == "authors": # author list name = element.tagname value = [element.astext() for element in element.children] else: # standard fields (e.g. address) @@ -252,22 +247,24 @@ class RstReader(BaseReader): return output def _get_publisher(self, source_path): - extra_params = {'initial_header_level': '2', - 'syntax_highlight': 'short', - 'input_encoding': 'utf-8', - 'language_code': self._language_code, - 'halt_level': 2, - 'traceback': True, - 'warning_stream': StringIO(), - 'embed_stylesheet': False} - user_params = self.settings.get('DOCUTILS_SETTINGS') + extra_params = { + "initial_header_level": "2", + "syntax_highlight": "short", + "input_encoding": "utf-8", + "language_code": self._language_code, + "halt_level": 2, + "traceback": True, + "warning_stream": StringIO(), + "embed_stylesheet": False, + } + user_params = self.settings.get("DOCUTILS_SETTINGS") if user_params: extra_params.update(user_params) pub = docutils.core.Publisher( - writer=self.writer_class(), - destination_class=docutils.io.StringOutput) - pub.set_components('standalone', 'restructuredtext', 'html') + writer=self.writer_class(), destination_class=docutils.io.StringOutput + ) + pub.set_components("standalone", "restructuredtext", "html") pub.process_programmatic_settings(None, extra_params, None) pub.set_source(source_path=source_path) pub.publish() @@ -277,10 +274,10 @@ class RstReader(BaseReader): """Parses restructured text""" pub = self._get_publisher(source_path) parts = pub.writer.parts - content = parts.get('body') + content = parts.get("body") metadata = self._parse_metadata(pub.document, source_path) - metadata.setdefault('title', parts.get('title')) + metadata.setdefault("title", parts.get("title")) return content, metadata @@ -289,26 +286,26 @@ class MarkdownReader(BaseReader): """Reader for Markdown files""" enabled = bool(Markdown) - file_extensions = ['md', 'markdown', 'mkd', 'mdown'] + file_extensions = ["md", "markdown", "mkd", "mdown"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - settings = self.settings['MARKDOWN'] - settings.setdefault('extension_configs', {}) - settings.setdefault('extensions', []) - for extension in settings['extension_configs'].keys(): - if extension not in settings['extensions']: - settings['extensions'].append(extension) - if 'markdown.extensions.meta' not in settings['extensions']: - settings['extensions'].append('markdown.extensions.meta') + settings = self.settings["MARKDOWN"] + settings.setdefault("extension_configs", {}) + settings.setdefault("extensions", []) + for extension in settings["extension_configs"].keys(): + if extension not in settings["extensions"]: + settings["extensions"].append(extension) + if "markdown.extensions.meta" not in settings["extensions"]: + settings["extensions"].append("markdown.extensions.meta") self._source_path = None def _parse_metadata(self, meta): """Return the dict containing document metadata""" - formatted_fields = self.settings['FORMATTED_FIELDS'] + formatted_fields = self.settings["FORMATTED_FIELDS"] # prevent metadata extraction in fields - self._md.preprocessors.deregister('meta') + self._md.preprocessors.deregister("meta") output = {} for name, value in meta.items(): @@ -323,9 +320,10 @@ 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.', - name, self._source_path) + "Duplicate definition of `%s` " "for %s. Using first one.", + name, + self._source_path, + ) output[name] = self.process_metadata(name, value[0]) elif len(value) > 1: # handle list metadata as list of string @@ -339,11 +337,11 @@ class MarkdownReader(BaseReader): """Parse content and metadata of markdown files""" self._source_path = source_path - self._md = Markdown(**self.settings['MARKDOWN']) + self._md = Markdown(**self.settings["MARKDOWN"]) with pelican_open(source_path) as text: content = self._md.convert(text) - if hasattr(self._md, 'Meta'): + if hasattr(self._md, "Meta"): metadata = self._parse_metadata(self._md.Meta) else: metadata = {} @@ -353,17 +351,17 @@ class MarkdownReader(BaseReader): class HTMLReader(BaseReader): """Parses HTML files as input, looking for meta, title, and body tags""" - file_extensions = ['htm', 'html'] + file_extensions = ["htm", "html"] enabled = True class _HTMLParser(HTMLParser): def __init__(self, settings, filename): super().__init__(convert_charrefs=False) - self.body = '' + self.body = "" self.metadata = {} self.settings = settings - self._data_buffer = '' + self._data_buffer = "" self._filename = filename @@ -374,94 +372,100 @@ class HTMLReader(BaseReader): self._in_tags = False def handle_starttag(self, tag, attrs): - if tag == 'head' and self._in_top_level: + if tag == "head" and self._in_top_level: self._in_top_level = False self._in_head = True - elif tag == 'title' and self._in_head: + elif tag == "title" and self._in_head: self._in_title = True - self._data_buffer = '' - elif tag == 'body' and self._in_top_level: + self._data_buffer = "" + elif tag == "body" and self._in_top_level: self._in_top_level = False self._in_body = True - self._data_buffer = '' - elif tag == 'meta' and self._in_head: + self._data_buffer = "" + elif tag == "meta" and self._in_head: self._handle_meta_tag(attrs) elif self._in_body: self._data_buffer += self.build_tag(tag, attrs, False) def handle_endtag(self, tag): - if tag == 'head': + if tag == "head": if self._in_head: self._in_head = False self._in_top_level = True - elif self._in_head and tag == 'title': + elif self._in_head and tag == "title": self._in_title = False - self.metadata['title'] = self._data_buffer - elif tag == 'body': + self.metadata["title"] = self._data_buffer + elif tag == "body": self.body = self._data_buffer self._in_body = False self._in_top_level = True elif self._in_body: - self._data_buffer += ''.format(escape(tag)) + self._data_buffer += f"" def handle_startendtag(self, tag, attrs): - if tag == 'meta' and self._in_head: + if tag == "meta" and self._in_head: self._handle_meta_tag(attrs) if self._in_body: self._data_buffer += self.build_tag(tag, attrs, True) def handle_comment(self, data): - self._data_buffer += ''.format(data) + self._data_buffer += f"" def handle_data(self, data): self._data_buffer += data def handle_entityref(self, data): - self._data_buffer += '&{};'.format(data) + self._data_buffer += f"&{data};" def handle_charref(self, data): - self._data_buffer += '&#{};'.format(data) + self._data_buffer += f"&#{data};" def build_tag(self, tag, attrs, close_tag): - result = '<{}'.format(escape(tag)) + result = f"<{escape(tag)}" for k, v in attrs: - result += ' ' + escape(k) + result += " " + escape(k) if v is not None: # If the attribute value contains a double quote, surround # with single quotes, otherwise use double quotes. if '"' in v: - result += "='{}'".format(escape(v, quote=False)) + result += f"='{escape(v, quote=False)}'" else: - result += '="{}"'.format(escape(v, quote=False)) + result += f'="{escape(v, quote=False)}"' if close_tag: - return result + ' />' - return result + '>' + return result + " />" + return result + ">" def _handle_meta_tag(self, attrs): - name = self._attr_value(attrs, 'name') + name = self._attr_value(attrs, "name") if name is None: - attr_list = ['{}="{}"'.format(k, v) for k, v in attrs] - attr_serialized = ', '.join(attr_list) - logger.warning("Meta tag in file %s does not have a 'name' " - "attribute, skipping. Attributes: %s", - self._filename, attr_serialized) + attr_list = [f'{k}="{v}"' for k, v in attrs] + attr_serialized = ", ".join(attr_list) + logger.warning( + "Meta tag in file %s does not have a 'name' " + "attribute, skipping. Attributes: %s", + self._filename, + attr_serialized, + ) return name = name.lower() - contents = self._attr_value(attrs, 'content', '') + contents = self._attr_value(attrs, "content", "") if not contents: - contents = self._attr_value(attrs, 'contents', '') + contents = self._attr_value(attrs, "contents", "") if contents: logger.warning( "Meta tag attribute 'contents' used in file %s, should" " be changed to 'content'", self._filename, - extra={'limit_msg': "Other files have meta tag " - "attribute 'contents' that should " - "be changed to 'content'"}) + extra={ + "limit_msg": "Other files have meta tag " + "attribute 'contents' that should " + "be changed to 'content'" + }, + ) - if name == 'keywords': - name = 'tags' + if name == "keywords": + name = "tags" if name in self.metadata: # if this metadata already exists (i.e. a previous tag with the @@ -501,22 +505,23 @@ class Readers(FileStampDataCacher): """ - def __init__(self, settings=None, cache_name=''): + def __init__(self, settings=None, cache_name=""): self.settings = settings or {} self.readers = {} self.reader_classes = {} for cls in [BaseReader] + BaseReader.__subclasses__(): if not cls.enabled: - logger.debug('Missing dependencies for %s', - ', '.join(cls.file_extensions)) + logger.debug( + "Missing dependencies for %s", ", ".join(cls.file_extensions) + ) continue for ext in cls.file_extensions: self.reader_classes[ext] = cls - if self.settings['READERS']: - self.reader_classes.update(self.settings['READERS']) + if self.settings["READERS"]: + self.reader_classes.update(self.settings["READERS"]) signals.readers_init.send(self) @@ -527,53 +532,67 @@ class Readers(FileStampDataCacher): self.readers[fmt] = reader_class(self.settings) # set up caching - cache_this_level = (cache_name != '' and - self.settings['CONTENT_CACHING_LAYER'] == 'reader') - caching_policy = cache_this_level and self.settings['CACHE_CONTENT'] - load_policy = cache_this_level and self.settings['LOAD_CONTENT_CACHE'] + cache_this_level = ( + cache_name != "" and self.settings["CONTENT_CACHING_LAYER"] == "reader" + ) + caching_policy = cache_this_level and self.settings["CACHE_CONTENT"] + load_policy = cache_this_level and self.settings["LOAD_CONTENT_CACHE"] super().__init__(settings, cache_name, caching_policy, load_policy) @property def extensions(self): return self.readers.keys() - def read_file(self, base_path, path, content_class=Page, fmt=None, - context=None, preread_signal=None, preread_sender=None, - context_signal=None, context_sender=None): + def read_file( + self, + base_path, + path, + content_class=Page, + fmt=None, + context=None, + preread_signal=None, + preread_sender=None, + context_signal=None, + context_sender=None, + ): """Return a content object parsed with the given format.""" path = os.path.abspath(os.path.join(base_path, path)) source_path = posixize_path(os.path.relpath(path, base_path)) - logger.debug( - 'Read file %s -> %s', - source_path, content_class.__name__) + 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:] if fmt not in self.readers: - raise TypeError( - 'Pelican does not know how to parse %s', path) + raise TypeError("Pelican does not know how to parse %s", path) if preread_signal: - logger.debug( - 'Signal %s.send(%s)', - preread_signal.name, preread_sender) + logger.debug("Signal %s.send(%s)", preread_signal.name, preread_sender) preread_signal.send(preread_sender) reader = self.readers[fmt] - metadata = _filter_discardable_metadata(default_metadata( - settings=self.settings, process=reader.process_metadata)) - metadata.update(path_metadata( - full_path=path, source_path=source_path, - settings=self.settings)) - metadata.update(_filter_discardable_metadata(parse_path_metadata( - source_path=source_path, settings=self.settings, - process=reader.process_metadata))) + metadata = _filter_discardable_metadata( + default_metadata(settings=self.settings, process=reader.process_metadata) + ) + metadata.update( + path_metadata( + full_path=path, source_path=source_path, settings=self.settings + ) + ) + metadata.update( + _filter_discardable_metadata( + parse_path_metadata( + source_path=source_path, + settings=self.settings, + process=reader.process_metadata, + ) + ) + ) reader_name = reader.__class__.__name__ - metadata['reader'] = reader_name.replace('Reader', '').lower() + metadata["reader"] = reader_name.replace("Reader", "").lower() content, reader_metadata = self.get_cached_data(path, (None, None)) if content is None: @@ -587,14 +606,14 @@ class Readers(FileStampDataCacher): find_empty_alt(content, path) # eventually filter the content with typogrify if asked so - if self.settings['TYPOGRIFY']: + if self.settings["TYPOGRIFY"]: from typogrify.filters import typogrify import smartypants - typogrify_dashes = self.settings['TYPOGRIFY_DASHES'] - if typogrify_dashes == 'oldschool': + typogrify_dashes = self.settings["TYPOGRIFY_DASHES"] + if typogrify_dashes == "oldschool": smartypants.Attr.default = smartypants.Attr.set2 - elif typogrify_dashes == 'oldschool_inverted': + elif typogrify_dashes == "oldschool_inverted": smartypants.Attr.default = smartypants.Attr.set3 else: smartypants.Attr.default = smartypants.Attr.set1 @@ -608,31 +627,32 @@ class Readers(FileStampDataCacher): def typogrify_wrapper(text): """Ensures ignore_tags feature is backward compatible""" try: - return typogrify( - text, - self.settings['TYPOGRIFY_IGNORE_TAGS']) + return typogrify(text, self.settings["TYPOGRIFY_IGNORE_TAGS"]) except TypeError: return typogrify(text) if content: content = typogrify_wrapper(content) - if 'title' in metadata: - metadata['title'] = typogrify_wrapper(metadata['title']) + if "title" in metadata: + metadata["title"] = typogrify_wrapper(metadata["title"]) - if 'summary' in metadata: - metadata['summary'] = typogrify_wrapper(metadata['summary']) + if "summary" in metadata: + metadata["summary"] = typogrify_wrapper(metadata["summary"]) if context_signal: logger.debug( - 'Signal %s.send(%s, )', - context_signal.name, - context_sender) + "Signal %s.send(%s, )", context_signal.name, context_sender + ) context_signal.send(context_sender, metadata=metadata) - return content_class(content=content, metadata=metadata, - settings=self.settings, source_path=path, - context=context) + return content_class( + content=content, + metadata=metadata, + settings=self.settings, + source_path=path, + context=context, + ) def find_empty_alt(content, path): @@ -642,7 +662,8 @@ def find_empty_alt(content, path): as they are really likely to be accessibility flaws. """ - imgs = re.compile(r""" + imgs = re.compile( + r""" (?: # src before alt ]* src=(['"])(.*?)\5 ) - """, re.X) + """, + re.X, + ) for match in re.findall(imgs, content): logger.warning( - 'Empty alt attribute for image %s in %s', - os.path.basename(match[1] + match[5]), path, - extra={'limit_msg': 'Other images have empty alt attributes'}) + "Empty alt attribute for image %s in %s", + os.path.basename(match[1] + match[5]), + path, + extra={"limit_msg": "Other images have empty alt attributes"}, + ) def default_metadata(settings=None, process=None): metadata = {} if settings: - for name, value in dict(settings.get('DEFAULT_METADATA', {})).items(): + for name, value in dict(settings.get("DEFAULT_METADATA", {})).items(): if process: value = process(name, value) metadata[name] = value - if 'DEFAULT_CATEGORY' in settings: - value = settings['DEFAULT_CATEGORY'] + if "DEFAULT_CATEGORY" in settings: + value = settings["DEFAULT_CATEGORY"] if process: - value = process('category', value) - metadata['category'] = value - if settings.get('DEFAULT_DATE', None) and \ - settings['DEFAULT_DATE'] != 'fs': - if isinstance(settings['DEFAULT_DATE'], str): - metadata['date'] = get_date(settings['DEFAULT_DATE']) + value = process("category", value) + metadata["category"] = value + if settings.get("DEFAULT_DATE", None) and settings["DEFAULT_DATE"] != "fs": + if isinstance(settings["DEFAULT_DATE"], str): + metadata["date"] = get_date(settings["DEFAULT_DATE"]) else: - metadata['date'] = datetime.datetime(*settings['DEFAULT_DATE']) + metadata["date"] = datetime.datetime(*settings["DEFAULT_DATE"]) return metadata def path_metadata(full_path, source_path, settings=None): metadata = {} if settings: - if settings.get('DEFAULT_DATE', None) == 'fs': - metadata['date'] = datetime.datetime.fromtimestamp( - os.stat(full_path).st_mtime) - metadata['modified'] = metadata['date'] + if settings.get("DEFAULT_DATE", None) == "fs": + metadata["date"] = datetime.datetime.fromtimestamp( + os.stat(full_path).st_mtime + ) + metadata["modified"] = metadata["date"] # Apply EXTRA_PATH_METADATA for the source path and the paths of any # parent directories. Sorting EPM first ensures that the most specific # path wins conflicts. - epm = settings.get('EXTRA_PATH_METADATA', {}) + epm = settings.get("EXTRA_PATH_METADATA", {}) for path, meta in sorted(epm.items()): # Enforce a trailing slash when checking for parent directories. # This prevents false positives when one file or directory's name # is a prefix of another's. - dirpath = posixize_path(os.path.join(path, '')) + dirpath = posixize_path(os.path.join(path, "")) if source_path == path or source_path.startswith(dirpath): metadata.update(meta) @@ -736,11 +761,10 @@ def parse_path_metadata(source_path, settings=None, process=None): subdir = os.path.basename(dirname) if settings: checks = [] - for key, data in [('FILENAME_METADATA', base), - ('PATH_METADATA', source_path)]: + for key, data in [("FILENAME_METADATA", base), ("PATH_METADATA", source_path)]: checks.append((settings.get(key, None), data)) - if settings.get('USE_FOLDER_AS_CATEGORY', None): - checks.append(('(?P.*)', subdir)) + if settings.get("USE_FOLDER_AS_CATEGORY", None): + checks.append(("(?P.*)", subdir)) for regexp, data in checks: if regexp and data: match = re.match(regexp, data) diff --git a/pelican/rstdirectives.py b/pelican/rstdirectives.py index 500c8578..0a549424 100644 --- a/pelican/rstdirectives.py +++ b/pelican/rstdirectives.py @@ -11,26 +11,26 @@ import pelican.settings as pys class Pygments(Directive): - """ Source code syntax highlighting. - """ + """Source code syntax highlighting.""" + required_arguments = 1 optional_arguments = 0 final_argument_whitespace = True option_spec = { - 'anchorlinenos': directives.flag, - 'classprefix': directives.unchanged, - 'hl_lines': directives.unchanged, - 'lineanchors': directives.unchanged, - 'linenos': directives.unchanged, - 'linenospecial': directives.nonnegative_int, - 'linenostart': directives.nonnegative_int, - 'linenostep': directives.nonnegative_int, - 'lineseparator': directives.unchanged, - 'linespans': directives.unchanged, - 'nobackground': directives.flag, - 'nowrap': directives.flag, - 'tagsfile': directives.unchanged, - 'tagurlformat': directives.unchanged, + "anchorlinenos": directives.flag, + "classprefix": directives.unchanged, + "hl_lines": directives.unchanged, + "lineanchors": directives.unchanged, + "linenos": directives.unchanged, + "linenospecial": directives.nonnegative_int, + "linenostart": directives.nonnegative_int, + "linenostep": directives.nonnegative_int, + "lineseparator": directives.unchanged, + "linespans": directives.unchanged, + "nobackground": directives.flag, + "nowrap": directives.flag, + "tagsfile": directives.unchanged, + "tagurlformat": directives.unchanged, } has_content = True @@ -49,28 +49,30 @@ class Pygments(Directive): if k not in self.options: self.options[k] = v - if ('linenos' in self.options and - self.options['linenos'] not in ('table', 'inline')): - if self.options['linenos'] == 'none': - self.options.pop('linenos') + if "linenos" in self.options and self.options["linenos"] not in ( + "table", + "inline", + ): + if self.options["linenos"] == "none": + self.options.pop("linenos") else: - self.options['linenos'] = 'table' + self.options["linenos"] = "table" - for flag in ('nowrap', 'nobackground', 'anchorlinenos'): + for flag in ("nowrap", "nobackground", "anchorlinenos"): if flag in self.options: self.options[flag] = True # noclasses should already default to False, but just in case... formatter = HtmlFormatter(noclasses=False, **self.options) - parsed = highlight('\n'.join(self.content), lexer, formatter) - return [nodes.raw('', parsed, format='html')] + parsed = highlight("\n".join(self.content), lexer, formatter) + return [nodes.raw("", parsed, format="html")] -directives.register_directive('code-block', Pygments) -directives.register_directive('sourcecode', Pygments) +directives.register_directive("code-block", Pygments) +directives.register_directive("sourcecode", Pygments) -_abbr_re = re.compile(r'\((.*)\)$', re.DOTALL) +_abbr_re = re.compile(r"\((.*)\)$", re.DOTALL) class abbreviation(nodes.Inline, nodes.TextElement): @@ -82,9 +84,9 @@ def abbr_role(typ, rawtext, text, lineno, inliner, options={}, content=[]): m = _abbr_re.search(text) if m is None: return [abbreviation(text, text)], [] - abbr = text[:m.start()].strip() + abbr = text[: m.start()].strip() expl = m.group(1) return [abbreviation(abbr, abbr, explanation=expl)], [] -roles.register_local_role('abbr', abbr_role) +roles.register_local_role("abbr", abbr_role) diff --git a/pelican/server.py b/pelican/server.py index 913c3761..61729bf1 100644 --- a/pelican/server.py +++ b/pelican/server.py @@ -14,38 +14,47 @@ except ImportError: from pelican.log import console # noqa: F401 from pelican.log import init as init_logging + logger = logging.getLogger(__name__) def parse_arguments(): parser = argparse.ArgumentParser( - description='Pelican Development Server', - formatter_class=argparse.ArgumentDefaultsHelpFormatter + description="Pelican Development Server", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "port", default=8000, type=int, nargs="?", help="Port to Listen On" + ) + parser.add_argument("server", default="", nargs="?", help="Interface to Listen On") + parser.add_argument("--ssl", action="store_true", help="Activate SSL listener") + parser.add_argument( + "--cert", + default="./cert.pem", + nargs="?", + 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", + ) + parser.add_argument( + "--path", + default=".", + help="Path to pelican source directory to serve. " + + "Relative to current directory", ) - parser.add_argument("port", default=8000, type=int, nargs="?", - help="Port to Listen On") - parser.add_argument("server", default="", nargs="?", - help="Interface to Listen On") - parser.add_argument('--ssl', action="store_true", - help='Activate SSL listener') - parser.add_argument('--cert', default="./cert.pem", nargs="?", - 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') - parser.add_argument('--path', default=".", - help='Path to pelican source directory to serve. ' + - 'Relative to current directory') return parser.parse_args() class ComplexHTTPRequestHandler(server.SimpleHTTPRequestHandler): - SUFFIXES = ['.html', '/index.html', '/', ''] + SUFFIXES = [".html", "/index.html", "/", ""] extensions_map = { **server.SimpleHTTPRequestHandler.extensions_map, - ** { + **{ # web fonts ".oft": "font/oft", ".sfnt": "font/sfnt", @@ -57,13 +66,13 @@ class ComplexHTTPRequestHandler(server.SimpleHTTPRequestHandler): def translate_path(self, path): # abandon query parameters - path = path.split('?', 1)[0] - path = path.split('#', 1)[0] + path = path.split("?", 1)[0] + path = path.split("#", 1)[0] # Don't forget explicit trailing slash when normalizing. Issue17324 - trailing_slash = path.rstrip().endswith('/') + trailing_slash = path.rstrip().endswith("/") path = urllib.parse.unquote(path) path = posixpath.normpath(path) - words = path.split('/') + words = path.split("/") words = filter(None, words) path = self.base_path for word in words: @@ -72,12 +81,12 @@ class ComplexHTTPRequestHandler(server.SimpleHTTPRequestHandler): continue path = os.path.join(path, word) if trailing_slash: - path += '/' + path += "/" return path def do_GET(self): # cut off a query string - original_path = self.path.split('?', 1)[0] + original_path = self.path.split("?", 1)[0] # try to find file self.path = self.get_path_that_exists(original_path) @@ -88,12 +97,12 @@ class ComplexHTTPRequestHandler(server.SimpleHTTPRequestHandler): def get_path_that_exists(self, original_path): # Try to strip trailing slash - trailing_slash = original_path.endswith('/') - original_path = original_path.rstrip('/') + trailing_slash = original_path.endswith("/") + original_path = original_path.rstrip("/") # Try to detect file by applying various suffixes tries = [] for suffix in self.SUFFIXES: - if not trailing_slash and suffix == '/': + if not trailing_slash and suffix == "/": # if original request does not have trailing slash, skip the '/' suffix # so that base class can redirect if needed continue @@ -101,18 +110,17 @@ class ComplexHTTPRequestHandler(server.SimpleHTTPRequestHandler): if os.path.exists(self.translate_path(path)): return path tries.append(path) - logger.warning("Unable to find `%s` or variations:\n%s", - original_path, - '\n'.join(tries)) + logger.warning( + "Unable to find `%s` or variations:\n%s", original_path, "\n".join(tries) + ) return None def guess_type(self, path): - """Guess at the mime type for the specified file. - """ + """Guess at the mime type for the specified file.""" mimetype = server.SimpleHTTPRequestHandler.guess_type(self, path) # If the default guess is too generic, try the python-magic library - if mimetype == 'application/octet-stream' and magic_from_file: + if mimetype == "application/octet-stream" and magic_from_file: mimetype = magic_from_file(path, mime=True) return mimetype @@ -127,31 +135,33 @@ class RootedHTTPServer(server.HTTPServer): self.RequestHandlerClass.base_path = base_path -if __name__ == '__main__': +if __name__ == "__main__": init_logging(level=logging.INFO) - logger.warning("'python -m pelican.server' is deprecated.\nThe " - "Pelican development server should be run via " - "'pelican --listen' or 'pelican -l'.\nThis can be combined " - "with regeneration as 'pelican -lr'.\nRerun 'pelican-" - "quickstart' to get new Makefile and tasks.py files.") + logger.warning( + "'python -m pelican.server' is deprecated.\nThe " + "Pelican development server should be run via " + "'pelican --listen' or 'pelican -l'.\nThis can be combined " + "with regeneration as 'pelican -lr'.\nRerun 'pelican-" + "quickstart' to get new Makefile and tasks.py files." + ) args = parse_arguments() RootedHTTPServer.allow_reuse_address = True try: httpd = RootedHTTPServer( - args.path, (args.server, args.port), ComplexHTTPRequestHandler) + args.path, (args.server, args.port), ComplexHTTPRequestHandler + ) if args.ssl: httpd.socket = ssl.wrap_socket( - httpd.socket, keyfile=args.key, - certfile=args.cert, server_side=True) + httpd.socket, keyfile=args.key, certfile=args.cert, server_side=True + ) except ssl.SSLError as e: - logger.error("Couldn't open certificate file %s or key file %s", - args.cert, args.key) - logger.error("Could not listen on port %s, server %s.", - args.port, args.server) - sys.exit(getattr(e, 'exitcode', 1)) + logger.error( + "Couldn't open certificate file %s or key file %s", args.cert, args.key + ) + logger.error("Could not listen on port %s, server %s.", args.port, args.server) + sys.exit(getattr(e, "exitcode", 1)) - logger.info("Serving at port %s, server %s.", - args.port, args.server) + logger.info("Serving at port %s, server %s.", args.port, args.server) try: httpd.serve_forever() except KeyboardInterrupt: diff --git a/pelican/settings.py b/pelican/settings.py index 5b495e86..33ec210a 100644 --- a/pelican/settings.py +++ b/pelican/settings.py @@ -5,7 +5,9 @@ import locale import logging import os import re +import sys from os.path import isabs +from pathlib import Path from pelican.log import LimitFilter @@ -13,156 +15,163 @@ from pelican.log import LimitFilter def load_source(name, path): 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 logger = logging.getLogger(__name__) -DEFAULT_THEME = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'themes', 'notmyidea') +DEFAULT_THEME = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "themes", "notmyidea" +) DEFAULT_CONFIG = { - 'PATH': os.curdir, - 'ARTICLE_PATHS': [''], - 'ARTICLE_EXCLUDES': [], - 'PAGE_PATHS': ['pages'], - 'PAGE_EXCLUDES': [], - 'THEME': DEFAULT_THEME, - 'OUTPUT_PATH': 'output', - 'READERS': {}, - 'STATIC_PATHS': ['images'], - 'STATIC_EXCLUDES': [], - 'STATIC_EXCLUDE_SOURCES': True, - 'THEME_STATIC_DIR': 'theme', - 'THEME_STATIC_PATHS': ['static', ], - 'FEED_ALL_ATOM': 'feeds/all.atom.xml', - 'CATEGORY_FEED_ATOM': 'feeds/{slug}.atom.xml', - 'AUTHOR_FEED_ATOM': 'feeds/{slug}.atom.xml', - 'AUTHOR_FEED_RSS': 'feeds/{slug}.rss.xml', - 'TRANSLATION_FEED_ATOM': 'feeds/all-{lang}.atom.xml', - 'FEED_MAX_ITEMS': '', - 'RSS_FEED_SUMMARY_ONLY': True, - 'SITEURL': '', - 'SITENAME': 'A Pelican Blog', - 'DISPLAY_PAGES_ON_MENU': True, - 'DISPLAY_CATEGORIES_ON_MENU': True, - 'DOCUTILS_SETTINGS': {}, - 'OUTPUT_SOURCES': False, - 'OUTPUT_SOURCES_EXTENSION': '.text', - 'USE_FOLDER_AS_CATEGORY': True, - 'DEFAULT_CATEGORY': 'misc', - 'WITH_FUTURE_DATES': True, - 'CSS_FILE': 'main.css', - 'NEWEST_FIRST_ARCHIVES': True, - 'REVERSE_CATEGORY_ORDER': False, - 'DELETE_OUTPUT_DIRECTORY': False, - 'OUTPUT_RETENTION': [], - 'INDEX_SAVE_AS': 'index.html', - 'ARTICLE_URL': '{slug}.html', - 'ARTICLE_SAVE_AS': '{slug}.html', - 'ARTICLE_ORDER_BY': 'reversed-date', - 'ARTICLE_LANG_URL': '{slug}-{lang}.html', - 'ARTICLE_LANG_SAVE_AS': '{slug}-{lang}.html', - 'DRAFT_URL': 'drafts/{slug}.html', - 'DRAFT_SAVE_AS': 'drafts/{slug}.html', - 'DRAFT_LANG_URL': 'drafts/{slug}-{lang}.html', - 'DRAFT_LANG_SAVE_AS': 'drafts/{slug}-{lang}.html', - 'PAGE_URL': 'pages/{slug}.html', - 'PAGE_SAVE_AS': 'pages/{slug}.html', - 'PAGE_ORDER_BY': 'basename', - 'PAGE_LANG_URL': 'pages/{slug}-{lang}.html', - 'PAGE_LANG_SAVE_AS': 'pages/{slug}-{lang}.html', - 'DRAFT_PAGE_URL': 'drafts/pages/{slug}.html', - 'DRAFT_PAGE_SAVE_AS': 'drafts/pages/{slug}.html', - 'DRAFT_PAGE_LANG_URL': 'drafts/pages/{slug}-{lang}.html', - 'DRAFT_PAGE_LANG_SAVE_AS': 'drafts/pages/{slug}-{lang}.html', - 'STATIC_URL': '{path}', - 'STATIC_SAVE_AS': '{path}', - 'STATIC_CREATE_LINKS': False, - 'STATIC_CHECK_IF_MODIFIED': False, - 'CATEGORY_URL': 'category/{slug}.html', - 'CATEGORY_SAVE_AS': 'category/{slug}.html', - 'TAG_URL': 'tag/{slug}.html', - 'TAG_SAVE_AS': 'tag/{slug}.html', - 'AUTHOR_URL': 'author/{slug}.html', - 'AUTHOR_SAVE_AS': 'author/{slug}.html', - 'PAGINATION_PATTERNS': [ - (1, '{name}{extension}', '{name}{extension}'), - (2, '{name}{number}{extension}', '{name}{number}{extension}'), + "PATH": os.curdir, + "ARTICLE_PATHS": [""], + "ARTICLE_EXCLUDES": [], + "PAGE_PATHS": ["pages"], + "PAGE_EXCLUDES": [], + "THEME": DEFAULT_THEME, + "OUTPUT_PATH": "output", + "READERS": {}, + "STATIC_PATHS": ["images"], + "STATIC_EXCLUDES": [], + "STATIC_EXCLUDE_SOURCES": True, + "THEME_STATIC_DIR": "theme", + "THEME_STATIC_PATHS": [ + "static", ], - 'YEAR_ARCHIVE_URL': '', - 'YEAR_ARCHIVE_SAVE_AS': '', - 'MONTH_ARCHIVE_URL': '', - 'MONTH_ARCHIVE_SAVE_AS': '', - 'DAY_ARCHIVE_URL': '', - 'DAY_ARCHIVE_SAVE_AS': '', - 'RELATIVE_URLS': False, - 'DEFAULT_LANG': 'en', - 'ARTICLE_TRANSLATION_ID': 'slug', - 'PAGE_TRANSLATION_ID': 'slug', - 'DIRECT_TEMPLATES': ['index', 'tags', 'categories', 'authors', 'archives'], - 'THEME_TEMPLATES_OVERRIDES': [], - 'PAGINATED_TEMPLATES': {'index': None, 'tag': None, 'category': None, - 'author': None}, - 'PELICAN_CLASS': 'pelican.Pelican', - 'DEFAULT_DATE_FORMAT': '%a %d %B %Y', - 'DATE_FORMATS': {}, - 'MARKDOWN': { - 'extension_configs': { - 'markdown.extensions.codehilite': {'css_class': 'highlight'}, - 'markdown.extensions.extra': {}, - 'markdown.extensions.meta': {}, + "FEED_ALL_ATOM": "feeds/all.atom.xml", + "CATEGORY_FEED_ATOM": "feeds/{slug}.atom.xml", + "AUTHOR_FEED_ATOM": "feeds/{slug}.atom.xml", + "AUTHOR_FEED_RSS": "feeds/{slug}.rss.xml", + "TRANSLATION_FEED_ATOM": "feeds/all-{lang}.atom.xml", + "FEED_MAX_ITEMS": 100, + "RSS_FEED_SUMMARY_ONLY": True, + "SITEURL": "", + "SITENAME": "A Pelican Blog", + "DISPLAY_PAGES_ON_MENU": True, + "DISPLAY_CATEGORIES_ON_MENU": True, + "DOCUTILS_SETTINGS": {}, + "OUTPUT_SOURCES": False, + "OUTPUT_SOURCES_EXTENSION": ".text", + "USE_FOLDER_AS_CATEGORY": True, + "DEFAULT_CATEGORY": "misc", + "WITH_FUTURE_DATES": True, + "CSS_FILE": "main.css", + "NEWEST_FIRST_ARCHIVES": True, + "REVERSE_CATEGORY_ORDER": False, + "DELETE_OUTPUT_DIRECTORY": False, + "OUTPUT_RETENTION": [], + "INDEX_SAVE_AS": "index.html", + "ARTICLE_URL": "{slug}.html", + "ARTICLE_SAVE_AS": "{slug}.html", + "ARTICLE_ORDER_BY": "reversed-date", + "ARTICLE_LANG_URL": "{slug}-{lang}.html", + "ARTICLE_LANG_SAVE_AS": "{slug}-{lang}.html", + "DRAFT_URL": "drafts/{slug}.html", + "DRAFT_SAVE_AS": "drafts/{slug}.html", + "DRAFT_LANG_URL": "drafts/{slug}-{lang}.html", + "DRAFT_LANG_SAVE_AS": "drafts/{slug}-{lang}.html", + "PAGE_URL": "pages/{slug}.html", + "PAGE_SAVE_AS": "pages/{slug}.html", + "PAGE_ORDER_BY": "basename", + "PAGE_LANG_URL": "pages/{slug}-{lang}.html", + "PAGE_LANG_SAVE_AS": "pages/{slug}-{lang}.html", + "DRAFT_PAGE_URL": "drafts/pages/{slug}.html", + "DRAFT_PAGE_SAVE_AS": "drafts/pages/{slug}.html", + "DRAFT_PAGE_LANG_URL": "drafts/pages/{slug}-{lang}.html", + "DRAFT_PAGE_LANG_SAVE_AS": "drafts/pages/{slug}-{lang}.html", + "STATIC_URL": "{path}", + "STATIC_SAVE_AS": "{path}", + "STATIC_CREATE_LINKS": False, + "STATIC_CHECK_IF_MODIFIED": False, + "CATEGORY_URL": "category/{slug}.html", + "CATEGORY_SAVE_AS": "category/{slug}.html", + "TAG_URL": "tag/{slug}.html", + "TAG_SAVE_AS": "tag/{slug}.html", + "AUTHOR_URL": "author/{slug}.html", + "AUTHOR_SAVE_AS": "author/{slug}.html", + "PAGINATION_PATTERNS": [ + (1, "{name}{extension}", "{name}{extension}"), + (2, "{name}{number}{extension}", "{name}{number}{extension}"), + ], + "YEAR_ARCHIVE_URL": "", + "YEAR_ARCHIVE_SAVE_AS": "", + "MONTH_ARCHIVE_URL": "", + "MONTH_ARCHIVE_SAVE_AS": "", + "DAY_ARCHIVE_URL": "", + "DAY_ARCHIVE_SAVE_AS": "", + "RELATIVE_URLS": False, + "DEFAULT_LANG": "en", + "ARTICLE_TRANSLATION_ID": "slug", + "PAGE_TRANSLATION_ID": "slug", + "DIRECT_TEMPLATES": ["index", "tags", "categories", "authors", "archives"], + "THEME_TEMPLATES_OVERRIDES": [], + "PAGINATED_TEMPLATES": { + "index": None, + "tag": None, + "category": None, + "author": None, + }, + "PELICAN_CLASS": "pelican.Pelican", + "DEFAULT_DATE_FORMAT": "%a %d %B %Y", + "DATE_FORMATS": {}, + "MARKDOWN": { + "extension_configs": { + "markdown.extensions.codehilite": {"css_class": "highlight"}, + "markdown.extensions.extra": {}, + "markdown.extensions.meta": {}, }, - 'output_format': 'html5', + "output_format": "html5", }, - 'JINJA_FILTERS': {}, - 'JINJA_GLOBALS': {}, - 'JINJA_TESTS': {}, - 'JINJA_ENVIRONMENT': { - 'trim_blocks': True, - 'lstrip_blocks': True, - 'extensions': [], + "JINJA_FILTERS": {}, + "JINJA_GLOBALS": {}, + "JINJA_TESTS": {}, + "JINJA_ENVIRONMENT": { + "trim_blocks": True, + "lstrip_blocks": True, + "extensions": [], }, - 'LOG_FILTER': [], - 'LOCALE': [''], # defaults to user locale - 'DEFAULT_PAGINATION': False, - 'DEFAULT_ORPHANS': 0, - 'DEFAULT_METADATA': {}, - 'FILENAME_METADATA': r'(?P\d{4}-\d{2}-\d{2}).*', - 'PATH_METADATA': '', - 'EXTRA_PATH_METADATA': {}, - 'ARTICLE_PERMALINK_STRUCTURE': '', - 'TYPOGRIFY': False, - 'TYPOGRIFY_IGNORE_TAGS': [], - 'TYPOGRIFY_DASHES': 'default', - 'SUMMARY_END_SUFFIX': '…', - 'SUMMARY_MAX_LENGTH': 50, - 'PLUGIN_PATHS': [], - 'PLUGINS': None, - 'PYGMENTS_RST_OPTIONS': {}, - 'TEMPLATE_PAGES': {}, - 'TEMPLATE_EXTENSIONS': ['.html'], - 'IGNORE_FILES': ['.#*'], - 'SLUG_REGEX_SUBSTITUTIONS': [ - (r'[^\w\s-]', ''), # remove non-alphabetical/whitespace/'-' chars - (r'(?u)\A\s*', ''), # strip leading whitespace - (r'(?u)\s*\Z', ''), # strip trailing whitespace - (r'[-\s]+', '-'), # reduce multiple whitespace or '-' to single '-' + "LOG_FILTER": [], + "LOCALE": [""], # defaults to user locale + "DEFAULT_PAGINATION": False, + "DEFAULT_ORPHANS": 0, + "DEFAULT_METADATA": {}, + "FILENAME_METADATA": r"(?P\d{4}-\d{2}-\d{2}).*", + "PATH_METADATA": "", + "EXTRA_PATH_METADATA": {}, + "ARTICLE_PERMALINK_STRUCTURE": "", + "TYPOGRIFY": False, + "TYPOGRIFY_IGNORE_TAGS": [], + "TYPOGRIFY_DASHES": "default", + "SUMMARY_END_SUFFIX": "…", + "SUMMARY_MAX_LENGTH": 50, + "PLUGIN_PATHS": [], + "PLUGINS": None, + "PYGMENTS_RST_OPTIONS": {}, + "TEMPLATE_PAGES": {}, + "TEMPLATE_EXTENSIONS": [".html"], + "IGNORE_FILES": [".#*"], + "SLUG_REGEX_SUBSTITUTIONS": [ + (r"[^\w\s-]", ""), # remove non-alphabetical/whitespace/'-' chars + (r"(?u)\A\s*", ""), # strip leading whitespace + (r"(?u)\s*\Z", ""), # strip trailing whitespace + (r"[-\s]+", "-"), # reduce multiple whitespace or '-' to single '-' ], - 'INTRASITE_LINK_REGEX': '[{|](?P.*?)[|}]', - 'SLUGIFY_SOURCE': 'title', - 'SLUGIFY_USE_UNICODE': False, - 'SLUGIFY_PRESERVE_CASE': False, - 'CACHE_CONTENT': False, - 'CONTENT_CACHING_LAYER': 'reader', - 'CACHE_PATH': 'cache', - 'GZIP_CACHE': True, - 'CHECK_MODIFIED_METHOD': 'mtime', - 'LOAD_CONTENT_CACHE': False, - 'WRITE_SELECTED': [], - 'FORMATTED_FIELDS': ['summary'], - 'PORT': 8000, - 'BIND': '127.0.0.1', + "INTRASITE_LINK_REGEX": "[{|](?P.*?)[|}]", + "SLUGIFY_SOURCE": "title", + "SLUGIFY_USE_UNICODE": False, + "SLUGIFY_PRESERVE_CASE": False, + "CACHE_CONTENT": False, + "CONTENT_CACHING_LAYER": "reader", + "CACHE_PATH": "cache", + "GZIP_CACHE": True, + "CHECK_MODIFIED_METHOD": "mtime", + "LOAD_CONTENT_CACHE": False, + "FORMATTED_FIELDS": ["summary"], + "PORT": 8000, + "BIND": "127.0.0.1", } PYGMENTS_RST_OPTIONS = None @@ -182,20 +191,23 @@ def read_settings(path=None, override=None): def getabs(maybe_relative, base_path=path): if isabs(maybe_relative): return maybe_relative - return os.path.abspath(os.path.normpath(os.path.join( - os.path.dirname(base_path), maybe_relative))) + return os.path.abspath( + os.path.normpath( + os.path.join(os.path.dirname(base_path), maybe_relative) + ) + ) - for p in ['PATH', 'OUTPUT_PATH', 'THEME', 'CACHE_PATH']: + for p in ["PATH", "OUTPUT_PATH", "THEME", "CACHE_PATH"]: if settings.get(p) is not None: absp = getabs(settings[p]) # THEME may be a name rather than a path - if p != 'THEME' or os.path.exists(absp): + if p != "THEME" or os.path.exists(absp): settings[p] = absp - if settings.get('PLUGIN_PATHS') is not None: - settings['PLUGIN_PATHS'] = [getabs(pluginpath) - for pluginpath - in settings['PLUGIN_PATHS']] + if settings.get("PLUGIN_PATHS") is not None: + settings["PLUGIN_PATHS"] = [ + getabs(pluginpath) for pluginpath in settings["PLUGIN_PATHS"] + ] settings = dict(copy.deepcopy(DEFAULT_CONFIG), **settings) settings = configure_settings(settings) @@ -205,7 +217,7 @@ def read_settings(path=None, override=None): # variable here that we'll import from within Pygments.run (see # rstdirectives.py) to see what the user defaults were. global PYGMENTS_RST_OPTIONS - PYGMENTS_RST_OPTIONS = settings.get('PYGMENTS_RST_OPTIONS', None) + PYGMENTS_RST_OPTIONS = settings.get("PYGMENTS_RST_OPTIONS", None) return settings @@ -214,8 +226,7 @@ def get_settings_from_module(module=None): context = {} if module is not None: - context.update( - (k, v) for k, v in inspect.getmembers(module) if k.isupper()) + context.update((k, v) for k, v in inspect.getmembers(module) if k.isupper()) return context @@ -230,11 +241,12 @@ def get_settings_from_file(path): def get_jinja_environment(settings): """Sets the environment for Jinja""" - jinja_env = settings.setdefault('JINJA_ENVIRONMENT', - DEFAULT_CONFIG['JINJA_ENVIRONMENT']) + jinja_env = settings.setdefault( + "JINJA_ENVIRONMENT", DEFAULT_CONFIG["JINJA_ENVIRONMENT"] + ) # Make sure we include the defaults if the user has set env variables - for key, value in DEFAULT_CONFIG['JINJA_ENVIRONMENT'].items(): + for key, value in DEFAULT_CONFIG["JINJA_ENVIRONMENT"].items(): if key not in jinja_env: jinja_env[key] = value @@ -245,14 +257,14 @@ def _printf_s_to_format_field(printf_string, format_field): """Tries to replace %s with {format_field} in the provided printf_string. Raises ValueError in case of failure. """ - TEST_STRING = 'PELICAN_PRINTF_S_DEPRECATION' + TEST_STRING = "PELICAN_PRINTF_S_DEPRECATION" expected = printf_string % TEST_STRING - result = printf_string.replace('{', '{{').replace('}', '}}') \ - % '{{{}}}'.format(format_field) + result = printf_string.replace("{", "{{").replace("}", "}}") % "{{{}}}".format( + format_field + ) if result.format(**{format_field: TEST_STRING}) != expected: - raise ValueError('Failed to safely replace %s with {{{}}}'.format( - format_field)) + raise ValueError(f"Failed to safely replace %s with {{{format_field}}}") return result @@ -263,115 +275,140 @@ def handle_deprecated_settings(settings): """ # PLUGIN_PATH -> PLUGIN_PATHS - if 'PLUGIN_PATH' in settings: - logger.warning('PLUGIN_PATH setting has been replaced by ' - 'PLUGIN_PATHS, moving it to the new setting name.') - settings['PLUGIN_PATHS'] = settings['PLUGIN_PATH'] - del settings['PLUGIN_PATH'] + if "PLUGIN_PATH" in settings: + logger.warning( + "PLUGIN_PATH setting has been replaced by " + "PLUGIN_PATHS, moving it to the new setting name." + ) + settings["PLUGIN_PATHS"] = settings["PLUGIN_PATH"] + del settings["PLUGIN_PATH"] # PLUGIN_PATHS: str -> [str] - if isinstance(settings.get('PLUGIN_PATHS'), str): - logger.warning("Defining PLUGIN_PATHS setting as string " - "has been deprecated (should be a list)") - settings['PLUGIN_PATHS'] = [settings['PLUGIN_PATHS']] + if isinstance(settings.get("PLUGIN_PATHS"), str): + logger.warning( + "Defining PLUGIN_PATHS setting as string " + "has been deprecated (should be a list)" + ) + settings["PLUGIN_PATHS"] = [settings["PLUGIN_PATHS"]] # JINJA_EXTENSIONS -> JINJA_ENVIRONMENT > extensions - if 'JINJA_EXTENSIONS' in settings: - logger.warning('JINJA_EXTENSIONS setting has been deprecated, ' - 'moving it to JINJA_ENVIRONMENT setting.') - settings['JINJA_ENVIRONMENT']['extensions'] = \ - settings['JINJA_EXTENSIONS'] - del settings['JINJA_EXTENSIONS'] + if "JINJA_EXTENSIONS" in settings: + logger.warning( + "JINJA_EXTENSIONS setting has been deprecated, " + "moving it to JINJA_ENVIRONMENT setting." + ) + settings["JINJA_ENVIRONMENT"]["extensions"] = settings["JINJA_EXTENSIONS"] + del settings["JINJA_EXTENSIONS"] # {ARTICLE,PAGE}_DIR -> {ARTICLE,PAGE}_PATHS - for key in ['ARTICLE', 'PAGE']: - old_key = key + '_DIR' - new_key = key + '_PATHS' + for key in ["ARTICLE", "PAGE"]: + old_key = key + "_DIR" + new_key = key + "_PATHS" if old_key in settings: logger.warning( - 'Deprecated setting %s, moving it to %s list', - old_key, new_key) - settings[new_key] = [settings[old_key]] # also make a list + "Deprecated setting %s, moving it to %s list", old_key, new_key + ) + settings[new_key] = [settings[old_key]] # also make a list del settings[old_key] # EXTRA_TEMPLATES_PATHS -> THEME_TEMPLATES_OVERRIDES - if 'EXTRA_TEMPLATES_PATHS' in settings: - logger.warning('EXTRA_TEMPLATES_PATHS is deprecated use ' - 'THEME_TEMPLATES_OVERRIDES instead.') - if ('THEME_TEMPLATES_OVERRIDES' in settings and - settings['THEME_TEMPLATES_OVERRIDES']): + if "EXTRA_TEMPLATES_PATHS" in settings: + logger.warning( + "EXTRA_TEMPLATES_PATHS is deprecated use " + "THEME_TEMPLATES_OVERRIDES instead." + ) + if ( + "THEME_TEMPLATES_OVERRIDES" in settings + and settings["THEME_TEMPLATES_OVERRIDES"] + ): raise Exception( - 'Setting both EXTRA_TEMPLATES_PATHS and ' - 'THEME_TEMPLATES_OVERRIDES is not permitted. Please move to ' - 'only setting THEME_TEMPLATES_OVERRIDES.') - settings['THEME_TEMPLATES_OVERRIDES'] = \ - settings['EXTRA_TEMPLATES_PATHS'] - del settings['EXTRA_TEMPLATES_PATHS'] + "Setting both EXTRA_TEMPLATES_PATHS and " + "THEME_TEMPLATES_OVERRIDES is not permitted. Please move to " + "only setting THEME_TEMPLATES_OVERRIDES." + ) + settings["THEME_TEMPLATES_OVERRIDES"] = settings["EXTRA_TEMPLATES_PATHS"] + del settings["EXTRA_TEMPLATES_PATHS"] # MD_EXTENSIONS -> MARKDOWN - if 'MD_EXTENSIONS' in settings: - logger.warning('MD_EXTENSIONS is deprecated use MARKDOWN ' - 'instead. Falling back to the default.') - settings['MARKDOWN'] = DEFAULT_CONFIG['MARKDOWN'] + if "MD_EXTENSIONS" in settings: + logger.warning( + "MD_EXTENSIONS is deprecated use MARKDOWN " + "instead. Falling back to the default." + ) + settings["MARKDOWN"] = DEFAULT_CONFIG["MARKDOWN"] # LESS_GENERATOR -> Webassets plugin # FILES_TO_COPY -> STATIC_PATHS, EXTRA_PATH_METADATA for old, new, doc in [ - ('LESS_GENERATOR', 'the Webassets plugin', None), - ('FILES_TO_COPY', 'STATIC_PATHS and EXTRA_PATH_METADATA', - 'https://github.com/getpelican/pelican/' - 'blob/master/docs/settings.rst#path-metadata'), + ("LESS_GENERATOR", "the Webassets plugin", None), + ( + "FILES_TO_COPY", + "STATIC_PATHS and EXTRA_PATH_METADATA", + "https://github.com/getpelican/pelican/" + "blob/master/docs/settings.rst#path-metadata", + ), ]: if old in settings: - message = 'The {} setting has been removed in favor of {}'.format( - old, new) + message = f"The {old} setting has been removed in favor of {new}" if doc: - message += ', see {} for details'.format(doc) + message += f", see {doc} for details" logger.warning(message) # PAGINATED_DIRECT_TEMPLATES -> PAGINATED_TEMPLATES - if 'PAGINATED_DIRECT_TEMPLATES' in settings: - message = 'The {} setting has been removed in favor of {}'.format( - 'PAGINATED_DIRECT_TEMPLATES', 'PAGINATED_TEMPLATES') + if "PAGINATED_DIRECT_TEMPLATES" in settings: + message = "The {} setting has been removed in favor of {}".format( + "PAGINATED_DIRECT_TEMPLATES", "PAGINATED_TEMPLATES" + ) logger.warning(message) # set PAGINATED_TEMPLATES - if 'PAGINATED_TEMPLATES' not in settings: - settings['PAGINATED_TEMPLATES'] = { - 'tag': None, 'category': None, 'author': None} + if "PAGINATED_TEMPLATES" not in settings: + settings["PAGINATED_TEMPLATES"] = { + "tag": None, + "category": None, + "author": None, + } - for t in settings['PAGINATED_DIRECT_TEMPLATES']: - if t not in settings['PAGINATED_TEMPLATES']: - settings['PAGINATED_TEMPLATES'][t] = None - del settings['PAGINATED_DIRECT_TEMPLATES'] + for t in settings["PAGINATED_DIRECT_TEMPLATES"]: + if t not in settings["PAGINATED_TEMPLATES"]: + settings["PAGINATED_TEMPLATES"][t] = None + del settings["PAGINATED_DIRECT_TEMPLATES"] # {SLUG,CATEGORY,TAG,AUTHOR}_SUBSTITUTIONS -> # {SLUG,CATEGORY,TAG,AUTHOR}_REGEX_SUBSTITUTIONS - url_settings_url = \ - 'http://docs.getpelican.com/en/latest/settings.html#url-settings' - flavours = {'SLUG', 'CATEGORY', 'TAG', 'AUTHOR'} - old_values = {f: settings[f + '_SUBSTITUTIONS'] - for f in flavours if f + '_SUBSTITUTIONS' in settings} - new_values = {f: settings[f + '_REGEX_SUBSTITUTIONS'] - for f in flavours if f + '_REGEX_SUBSTITUTIONS' in settings} + url_settings_url = "http://docs.getpelican.com/en/latest/settings.html#url-settings" + flavours = {"SLUG", "CATEGORY", "TAG", "AUTHOR"} + old_values = { + f: settings[f + "_SUBSTITUTIONS"] + for f in flavours + if f + "_SUBSTITUTIONS" in settings + } + new_values = { + f: settings[f + "_REGEX_SUBSTITUTIONS"] + for f in flavours + if f + "_REGEX_SUBSTITUTIONS" in settings + } if old_values and new_values: raise Exception( - 'Setting both {new_key} and {old_key} (or variants thereof) is ' - 'not permitted. Please move to only setting {new_key}.' - .format(old_key='SLUG_SUBSTITUTIONS', - new_key='SLUG_REGEX_SUBSTITUTIONS')) + "Setting both {new_key} and {old_key} (or variants thereof) is " + "not permitted. Please move to only setting {new_key}.".format( + old_key="SLUG_SUBSTITUTIONS", new_key="SLUG_REGEX_SUBSTITUTIONS" + ) + ) if old_values: - message = ('{} and variants thereof are deprecated and will be ' - 'removed in the future. Please use {} and variants thereof ' - 'instead. Check {}.' - .format('SLUG_SUBSTITUTIONS', 'SLUG_REGEX_SUBSTITUTIONS', - url_settings_url)) + message = ( + "{} and variants thereof are deprecated and will be " + "removed in the future. Please use {} and variants thereof " + "instead. Check {}.".format( + "SLUG_SUBSTITUTIONS", "SLUG_REGEX_SUBSTITUTIONS", url_settings_url + ) + ) logger.warning(message) - if old_values.get('SLUG'): - for f in {'CATEGORY', 'TAG'}: + if old_values.get("SLUG"): + 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', []) + old_values[f] = old_values["SLUG"] + old_values[f] + old_values["AUTHOR"] = old_values.get("AUTHOR", []) for f in flavours: if old_values.get(f) is not None: regex_subs = [] @@ -384,117 +421,148 @@ def handle_deprecated_settings(settings): replace = False except ValueError: src, dst = tpl - regex_subs.append( - (re.escape(src), dst.replace('\\', r'\\'))) + regex_subs.append((re.escape(src), dst.replace("\\", r"\\"))) if replace: regex_subs += [ - (r'[^\w\s-]', ''), - (r'(?u)\A\s*', ''), - (r'(?u)\s*\Z', ''), - (r'[-\s]+', '-'), + (r"[^\w\s-]", ""), + (r"(?u)\A\s*", ""), + (r"(?u)\s*\Z", ""), + (r"[-\s]+", "-"), ] else: regex_subs += [ - (r'(?u)\A\s*', ''), - (r'(?u)\s*\Z', ''), + (r"(?u)\A\s*", ""), + (r"(?u)\s*\Z", ""), ] - settings[f + '_REGEX_SUBSTITUTIONS'] = regex_subs - settings.pop(f + '_SUBSTITUTIONS', None) + settings[f + "_REGEX_SUBSTITUTIONS"] = regex_subs + settings.pop(f + "_SUBSTITUTIONS", None) # `%s` -> '{slug}` or `{lang}` in FEED settings - for key in ['TRANSLATION_FEED_ATOM', - 'TRANSLATION_FEED_RSS' - ]: - if settings.get(key) and '%s' in settings[key]: - logger.warning('%%s usage in %s is deprecated, use {lang} ' - 'instead.', key) + for key in ["TRANSLATION_FEED_ATOM", "TRANSLATION_FEED_RSS"]: + if ( + settings.get(key) + and not isinstance(settings[key], Path) + and "%s" in settings[key] + ): + logger.warning("%%s usage in %s is deprecated, use {lang} " "instead.", key) try: - settings[key] = _printf_s_to_format_field( - settings[key], 'lang') + settings[key] = _printf_s_to_format_field(settings[key], "lang") except ValueError: - logger.warning('Failed to convert %%s to {lang} for %s. ' - 'Falling back to default.', key) + logger.warning( + "Failed to convert %%s to {lang} for %s. " + "Falling back to default.", + key, + ) settings[key] = DEFAULT_CONFIG[key] - for key in ['AUTHOR_FEED_ATOM', - 'AUTHOR_FEED_RSS', - 'CATEGORY_FEED_ATOM', - 'CATEGORY_FEED_RSS', - 'TAG_FEED_ATOM', - 'TAG_FEED_RSS', - ]: - if settings.get(key) and '%s' in settings[key]: - logger.warning('%%s usage in %s is deprecated, use {slug} ' - 'instead.', key) + for key in [ + "AUTHOR_FEED_ATOM", + "AUTHOR_FEED_RSS", + "CATEGORY_FEED_ATOM", + "CATEGORY_FEED_RSS", + "TAG_FEED_ATOM", + "TAG_FEED_RSS", + ]: + if ( + settings.get(key) + and not isinstance(settings[key], Path) + and "%s" in settings[key] + ): + logger.warning("%%s usage in %s is deprecated, use {slug} " "instead.", key) try: - settings[key] = _printf_s_to_format_field( - settings[key], 'slug') + settings[key] = _printf_s_to_format_field(settings[key], "slug") except ValueError: - logger.warning('Failed to convert %%s to {slug} for %s. ' - 'Falling back to default.', key) + logger.warning( + "Failed to convert %%s to {slug} for %s. " + "Falling back to default.", + key, + ) settings[key] = DEFAULT_CONFIG[key] # CLEAN_URLS - if settings.get('CLEAN_URLS', False): - logger.warning('Found deprecated `CLEAN_URLS` in settings.' - ' Modifying the following settings for the' - ' same behaviour.') + if settings.get("CLEAN_URLS", False): + logger.warning( + "Found deprecated `CLEAN_URLS` in settings." + " Modifying the following settings for the" + " same behaviour." + ) - settings['ARTICLE_URL'] = '{slug}/' - settings['ARTICLE_LANG_URL'] = '{slug}-{lang}/' - settings['PAGE_URL'] = 'pages/{slug}/' - settings['PAGE_LANG_URL'] = 'pages/{slug}-{lang}/' + settings["ARTICLE_URL"] = "{slug}/" + settings["ARTICLE_LANG_URL"] = "{slug}-{lang}/" + settings["PAGE_URL"] = "pages/{slug}/" + settings["PAGE_LANG_URL"] = "pages/{slug}-{lang}/" - for setting in ('ARTICLE_URL', 'ARTICLE_LANG_URL', 'PAGE_URL', - 'PAGE_LANG_URL'): + for setting in ("ARTICLE_URL", "ARTICLE_LANG_URL", "PAGE_URL", "PAGE_LANG_URL"): logger.warning("%s = '%s'", setting, settings[setting]) # AUTORELOAD_IGNORE_CACHE -> --ignore-cache - if settings.get('AUTORELOAD_IGNORE_CACHE'): - logger.warning('Found deprecated `AUTORELOAD_IGNORE_CACHE` in ' - 'settings. Use --ignore-cache instead.') - settings.pop('AUTORELOAD_IGNORE_CACHE') + if settings.get("AUTORELOAD_IGNORE_CACHE"): + logger.warning( + "Found deprecated `AUTORELOAD_IGNORE_CACHE` in " + "settings. Use --ignore-cache instead." + ) + settings.pop("AUTORELOAD_IGNORE_CACHE") # ARTICLE_PERMALINK_STRUCTURE - if settings.get('ARTICLE_PERMALINK_STRUCTURE', False): - logger.warning('Found deprecated `ARTICLE_PERMALINK_STRUCTURE` in' - ' settings. Modifying the following settings for' - ' the same behaviour.') + if settings.get("ARTICLE_PERMALINK_STRUCTURE", False): + logger.warning( + "Found deprecated `ARTICLE_PERMALINK_STRUCTURE` in" + " settings. Modifying the following settings for" + " the same behaviour." + ) - structure = settings['ARTICLE_PERMALINK_STRUCTURE'] + structure = settings["ARTICLE_PERMALINK_STRUCTURE"] # Convert %(variable) into {variable}. - structure = re.sub(r'%\((\w+)\)s', r'{\g<1>}', structure) + structure = re.sub(r"%\((\w+)\)s", r"{\g<1>}", structure) # Convert %x into {date:%x} for strftime - structure = re.sub(r'(%[A-z])', r'{date:\g<1>}', structure) + structure = re.sub(r"(%[A-z])", r"{date:\g<1>}", structure) # Strip a / prefix - structure = re.sub('^/', '', structure) + structure = re.sub("^/", "", structure) - for setting in ('ARTICLE_URL', 'ARTICLE_LANG_URL', 'PAGE_URL', - 'PAGE_LANG_URL', 'DRAFT_URL', 'DRAFT_LANG_URL', - 'ARTICLE_SAVE_AS', 'ARTICLE_LANG_SAVE_AS', - 'DRAFT_SAVE_AS', 'DRAFT_LANG_SAVE_AS', - 'PAGE_SAVE_AS', 'PAGE_LANG_SAVE_AS'): - settings[setting] = os.path.join(structure, - settings[setting]) + for setting in ( + "ARTICLE_URL", + "ARTICLE_LANG_URL", + "PAGE_URL", + "PAGE_LANG_URL", + "DRAFT_URL", + "DRAFT_LANG_URL", + "ARTICLE_SAVE_AS", + "ARTICLE_LANG_SAVE_AS", + "DRAFT_SAVE_AS", + "DRAFT_LANG_SAVE_AS", + "PAGE_SAVE_AS", + "PAGE_LANG_SAVE_AS", + ): + settings[setting] = os.path.join(structure, settings[setting]) logger.warning("%s = '%s'", setting, settings[setting]) # {,TAG,CATEGORY,TRANSLATION}_FEED -> {,TAG,CATEGORY,TRANSLATION}_FEED_ATOM - for new, old in [('FEED', 'FEED_ATOM'), ('TAG_FEED', 'TAG_FEED_ATOM'), - ('CATEGORY_FEED', 'CATEGORY_FEED_ATOM'), - ('TRANSLATION_FEED', 'TRANSLATION_FEED_ATOM')]: + for new, old in [ + ("FEED", "FEED_ATOM"), + ("TAG_FEED", "TAG_FEED_ATOM"), + ("CATEGORY_FEED", "CATEGORY_FEED_ATOM"), + ("TRANSLATION_FEED", "TRANSLATION_FEED_ATOM"), + ]: if settings.get(new, False): logger.warning( - 'Found deprecated `%(new)s` in settings. Modify %(new)s ' - 'to %(old)s in your settings and theme for the same ' - 'behavior. Temporarily setting %(old)s for backwards ' - 'compatibility.', - {'new': new, 'old': old} + "Found deprecated `%(new)s` in settings. Modify %(new)s " + "to %(old)s in your settings and theme for the same " + "behavior. Temporarily setting %(old)s for backwards " + "compatibility.", + {"new": new, "old": old}, ) settings[old] = settings[new] + # Warn if removed WRITE_SELECTED is present + if "WRITE_SELECTED" in settings: + logger.warning( + "WRITE_SELECTED is present in settings but this functionality was removed. " + "It will have no effect." + ) + return settings @@ -503,34 +571,28 @@ def configure_settings(settings): settings. Also, specify the log messages to be ignored. """ - if 'PATH' not in settings or not os.path.isdir(settings['PATH']): - raise Exception('You need to specify a path containing the content' - ' (see pelican --help for more information)') + if "PATH" not in settings or not os.path.isdir(settings["PATH"]): + raise Exception( + "You need to specify a path containing the content" + " (see pelican --help for more information)" + ) # specify the log messages to be ignored - log_filter = settings.get('LOG_FILTER', DEFAULT_CONFIG['LOG_FILTER']) + log_filter = settings.get("LOG_FILTER", DEFAULT_CONFIG["LOG_FILTER"]) LimitFilter._ignore.update(set(log_filter)) # lookup the theme in "pelican/themes" if the given one doesn't exist - if not os.path.isdir(settings['THEME']): + if not os.path.isdir(settings["THEME"]): theme_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - 'themes', - settings['THEME']) + os.path.dirname(os.path.abspath(__file__)), "themes", settings["THEME"] + ) if os.path.exists(theme_path): - settings['THEME'] = theme_path + settings["THEME"] = theme_path else: - raise Exception("Could not find the theme %s" - % settings['THEME']) - - # make paths selected for writing absolute if necessary - settings['WRITE_SELECTED'] = [ - os.path.abspath(path) for path in - settings.get('WRITE_SELECTED', DEFAULT_CONFIG['WRITE_SELECTED']) - ] + raise Exception("Could not find the theme %s" % settings["THEME"]) # standardize strings to lowercase strings - for key in ['DEFAULT_LANG']: + for key in ["DEFAULT_LANG"]: if key in settings: settings[key] = settings[key].lower() @@ -538,24 +600,26 @@ def configure_settings(settings): settings = get_jinja_environment(settings) # standardize strings to lists - for key in ['LOCALE']: + for key in ["LOCALE"]: if key in settings and isinstance(settings[key], str): settings[key] = [settings[key]] # check settings that must be a particular type for key, types in [ - ('OUTPUT_SOURCES_EXTENSION', str), - ('FILENAME_METADATA', str), + ("OUTPUT_SOURCES_EXTENSION", str), + ("FILENAME_METADATA", str), ]: 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)', - key, value, DEFAULT_CONFIG[key]) + "Detected misconfigured %s (%s), " "falling back to the default (%s)", + key, + value, + DEFAULT_CONFIG[key], + ) # try to set the different locales, fallback on the default. - locales = settings.get('LOCALE', DEFAULT_CONFIG['LOCALE']) + locales = settings.get("LOCALE", DEFAULT_CONFIG["LOCALE"]) for locale_ in locales: try: @@ -566,95 +630,111 @@ def configure_settings(settings): else: logger.warning( "Locale could not be set. Check the LOCALE setting, ensuring it " - "is valid and available on your system.") + "is valid and available on your system." + ) - if ('SITEURL' in settings): + if "SITEURL" in settings: # If SITEURL has a trailing slash, remove it and provide a warning - siteurl = settings['SITEURL'] - if (siteurl.endswith('/')): - settings['SITEURL'] = siteurl[:-1] + siteurl = settings["SITEURL"] + if siteurl.endswith("/"): + settings["SITEURL"] = siteurl[:-1] logger.warning("Removed extraneous trailing slash from SITEURL.") # If SITEURL is defined but FEED_DOMAIN isn't, # set FEED_DOMAIN to SITEURL - if 'FEED_DOMAIN' not in settings: - settings['FEED_DOMAIN'] = settings['SITEURL'] + if "FEED_DOMAIN" not in settings: + settings["FEED_DOMAIN"] = settings["SITEURL"] # check content caching layer and warn of incompatibilities - if settings.get('CACHE_CONTENT', False) and \ - settings.get('CONTENT_CACHING_LAYER', '') == 'generator' and \ - settings.get('WITH_FUTURE_DATES', False): + if ( + settings.get("CACHE_CONTENT", False) + and settings.get("CONTENT_CACHING_LAYER", "") == "generator" + and not settings.get("WITH_FUTURE_DATES", True) + ): logger.warning( "WITH_FUTURE_DATES conflicts with CONTENT_CACHING_LAYER " - "set to 'generator', use 'reader' layer instead") + "set to 'generator', use 'reader' layer instead" + ) # Warn if feeds are generated with both SITEURL & FEED_DOMAIN undefined feed_keys = [ - 'FEED_ATOM', 'FEED_RSS', - 'FEED_ALL_ATOM', 'FEED_ALL_RSS', - 'CATEGORY_FEED_ATOM', 'CATEGORY_FEED_RSS', - 'AUTHOR_FEED_ATOM', 'AUTHOR_FEED_RSS', - 'TAG_FEED_ATOM', 'TAG_FEED_RSS', - 'TRANSLATION_FEED_ATOM', 'TRANSLATION_FEED_RSS', + "FEED_ATOM", + "FEED_RSS", + "FEED_ALL_ATOM", + "FEED_ALL_RSS", + "CATEGORY_FEED_ATOM", + "CATEGORY_FEED_RSS", + "AUTHOR_FEED_ATOM", + "AUTHOR_FEED_RSS", + "TAG_FEED_ATOM", + "TAG_FEED_RSS", + "TRANSLATION_FEED_ATOM", + "TRANSLATION_FEED_RSS", ] 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') + if not settings.get("SITEURL"): + logger.warning( + "Feeds generated without SITEURL set properly may" " not be valid" + ) - if 'TIMEZONE' not in settings: + if "TIMEZONE" not in settings: logger.warning( - 'No timezone information specified in the settings. Assuming' - ' your timezone is UTC for feed generation. Check ' - 'https://docs.getpelican.com/en/latest/settings.html#TIMEZONE ' - 'for more information') + "No timezone information specified in the settings. Assuming" + " your timezone is UTC for feed generation. Check " + "https://docs.getpelican.com/en/latest/settings.html#TIMEZONE " + "for more information" + ) # fix up pagination rules from pelican.paginator import PaginationRule + pagination_rules = [ - PaginationRule(*r) for r in settings.get( - 'PAGINATION_PATTERNS', - DEFAULT_CONFIG['PAGINATION_PATTERNS'], + PaginationRule(*r) + for r in settings.get( + "PAGINATION_PATTERNS", + DEFAULT_CONFIG["PAGINATION_PATTERNS"], ) ] - settings['PAGINATION_PATTERNS'] = sorted( + settings["PAGINATION_PATTERNS"] = sorted( pagination_rules, key=lambda r: r[0], ) # Save people from accidentally setting a string rather than a list path_keys = ( - 'ARTICLE_EXCLUDES', - 'DEFAULT_METADATA', - 'DIRECT_TEMPLATES', - 'THEME_TEMPLATES_OVERRIDES', - 'FILES_TO_COPY', - 'IGNORE_FILES', - 'PAGINATED_DIRECT_TEMPLATES', - 'PLUGINS', - 'STATIC_EXCLUDES', - 'STATIC_PATHS', - 'THEME_STATIC_PATHS', - 'ARTICLE_PATHS', - 'PAGE_PATHS', + "ARTICLE_EXCLUDES", + "DEFAULT_METADATA", + "DIRECT_TEMPLATES", + "THEME_TEMPLATES_OVERRIDES", + "FILES_TO_COPY", + "IGNORE_FILES", + "PAGINATED_DIRECT_TEMPLATES", + "PLUGINS", + "STATIC_EXCLUDES", + "STATIC_PATHS", + "THEME_STATIC_PATHS", + "ARTICLE_PATHS", + "PAGE_PATHS", ) for PATH_KEY in filter(lambda k: k in settings, path_keys): if isinstance(settings[PATH_KEY], str): - logger.warning("Detected misconfiguration with %s setting " - "(must be a list), falling back to the default", - PATH_KEY) + logger.warning( + "Detected misconfiguration with %s setting " + "(must be a list), falling back to the default", + PATH_KEY, + ) settings[PATH_KEY] = DEFAULT_CONFIG[PATH_KEY] # Add {PAGE,ARTICLE}_PATHS to {ARTICLE,PAGE}_EXCLUDES - mutually_exclusive = ('ARTICLE', 'PAGE') + mutually_exclusive = ("ARTICLE", "PAGE") for type_1, type_2 in [mutually_exclusive, mutually_exclusive[::-1]]: try: - includes = settings[type_1 + '_PATHS'] - excludes = settings[type_2 + '_EXCLUDES'] + includes = settings[type_1 + "_PATHS"] + excludes = settings[type_2 + "_EXCLUDES"] for path in includes: if path not in excludes: excludes.append(path) except KeyError: - continue # setting not specified, nothing to do + continue # setting not specified, nothing to do return settings diff --git a/pelican/signals.py b/pelican/signals.py index 9b84a92a..4d232e34 100644 --- a/pelican/signals.py +++ b/pelican/signals.py @@ -1,4 +1,4 @@ raise ImportError( - 'Importing from `pelican.signals` is deprecated. ' - 'Use `from pelican import signals` or `import pelican.plugins.signals` instead.' + "Importing from `pelican.signals` is deprecated. " + "Use `from pelican import signals` or `import pelican.plugins.signals` instead." ) diff --git a/pelican/tests/TestPages/draft_page_markdown.md b/pelican/tests/TestPages/draft_page_markdown.md index fda71868..0f378a55 100644 --- a/pelican/tests/TestPages/draft_page_markdown.md +++ b/pelican/tests/TestPages/draft_page_markdown.md @@ -9,4 +9,4 @@ Used for pelican test The quick brown fox . -This page is a draft \ No newline at end of file +This page is a draft diff --git a/pelican/tests/build_test/conftest.py b/pelican/tests/build_test/conftest.py new file mode 100644 index 00000000..b1d1a54b --- /dev/null +++ b/pelican/tests/build_test/conftest.py @@ -0,0 +1,7 @@ +def pytest_addoption(parser): + parser.addoption( + "--check-build", + action="store", + default=False, + help="Check wheel contents.", + ) diff --git a/pelican/tests/build_test/test_build_files.py b/pelican/tests/build_test/test_build_files.py new file mode 100644 index 00000000..9aad990d --- /dev/null +++ b/pelican/tests/build_test/test_build_files.py @@ -0,0 +1,66 @@ +from re import match +import tarfile +from pathlib import Path +from zipfile import ZipFile + +import pytest + + +@pytest.mark.skipif( + "not config.getoption('--check-build')", + reason="Only run when --check-build is given", +) +def test_wheel_contents(pytestconfig): + """ + This test should test the contents of the wheel to make sure + that everything that is needed is included in the final build + """ + dist_folder = pytestconfig.getoption("--check-build") + wheels = Path(dist_folder).rglob("*.whl") + for wheel_file in wheels: + files_list = ZipFile(wheel_file).namelist() + # Check if theme files are copied to wheel + simple_theme = Path("./pelican/themes/simple/templates") + for x in simple_theme.iterdir(): + assert str(x) in files_list + + # Check if tool templates are copied to wheel + tools = Path("./pelican/tools/templates") + for x in tools.iterdir(): + assert str(x) in files_list + + assert "pelican/tools/templates/tasks.py.jinja2" in files_list + + +@pytest.mark.skipif( + "not config.getoption('--check-build')", + reason="Only run when --check-build is given", +) +@pytest.mark.parametrize( + "expected_file", + [ + ("THANKS"), + ("README.rst"), + ("CONTRIBUTING.rst"), + ("docs/changelog.rst"), + ("samples/"), + ], +) +def test_sdist_contents(pytestconfig, expected_file): + """ + This test should test the contents of the source distribution to make sure + that everything that is needed is included in the final build. + """ + dist_folder = pytestconfig.getoption("--check-build") + sdist_files = Path(dist_folder).rglob("*.tar.gz") + for dist in sdist_files: + files_list = tarfile.open(dist, "r:gz").getnames() + dir_matcher = "" + if expected_file.endswith("/"): + dir_matcher = ".*" + filtered_values = [ + path + for path in files_list + if match(rf"^pelican-\d\.\d\.\d/{expected_file}{dir_matcher}$", path) + ] + assert len(filtered_values) > 0 diff --git a/pelican/tests/content/2012-11-30_md_w_filename_meta#foo-bar.md b/pelican/tests/content/2012-11-30_md_w_filename_meta#foo-bar.md index cdccfc8a..8b3c0bcf 100644 --- a/pelican/tests/content/2012-11-30_md_w_filename_meta#foo-bar.md +++ b/pelican/tests/content/2012-11-30_md_w_filename_meta#foo-bar.md @@ -3,4 +3,3 @@ author: Alexis Métaireau Markdown with filename metadata =============================== - diff --git a/pelican/tests/content/article_with_markdown_markup_extensions.md b/pelican/tests/content/article_with_markdown_markup_extensions.md index 6cf56403..7eff4dcb 100644 --- a/pelican/tests/content/article_with_markdown_markup_extensions.md +++ b/pelican/tests/content/article_with_markdown_markup_extensions.md @@ -5,4 +5,3 @@ Title: Test Markdown extensions ## Level1 ### Level2 - diff --git a/pelican/tests/content/article_with_uppercase_metadata.rst b/pelican/tests/content/article_with_uppercase_metadata.rst index e26cdd13..ee79f55a 100644 --- a/pelican/tests/content/article_with_uppercase_metadata.rst +++ b/pelican/tests/content/article_with_uppercase_metadata.rst @@ -3,4 +3,3 @@ This is a super article ! ######################### :Category: Yeah - diff --git a/pelican/tests/content/article_without_category.rst b/pelican/tests/content/article_without_category.rst index ff47f6ef..1cfcba71 100644 --- a/pelican/tests/content/article_without_category.rst +++ b/pelican/tests/content/article_without_category.rst @@ -3,4 +3,3 @@ This is an article without category ! ##################################### This article should be in the DEFAULT_CATEGORY. - diff --git a/pelican/tests/content/bloggerexport.xml b/pelican/tests/content/bloggerexport.xml index 4bc0985a..a3b9cdf2 100644 --- a/pelican/tests/content/bloggerexport.xml +++ b/pelican/tests/content/bloggerexport.xml @@ -1064,4 +1064,4 @@ - \ No newline at end of file + diff --git a/pelican/tests/content/empty_with_bom.md b/pelican/tests/content/empty_with_bom.md index 5f282702..e02abfc9 100644 --- a/pelican/tests/content/empty_with_bom.md +++ b/pelican/tests/content/empty_with_bom.md @@ -1 +1 @@ - \ No newline at end of file + diff --git a/pelican/tests/content/wordpress_content_encoded b/pelican/tests/content/wordpress_content_encoded index da35de3b..eefff1e9 100644 --- a/pelican/tests/content/wordpress_content_encoded +++ b/pelican/tests/content/wordpress_content_encoded @@ -52,4 +52,3 @@ quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - diff --git a/pelican/tests/content/wordpressexport.xml b/pelican/tests/content/wordpressexport.xml index 9b194e8f..81ed7ea3 100644 --- a/pelican/tests/content/wordpressexport.xml +++ b/pelican/tests/content/wordpressexport.xml @@ -685,7 +685,52 @@ proident, sunt in culpa qui officia deserunt mollit anim id est laborum.]]>_edit_last - + + + Caption on image + http://thisisa.test/?p=176 + Thu, 01 Jan 1970 00:00:00 +0000 + bob + http://thisisa.test/?p=176 + + [/caption] + +[caption attachment_id="43" align="aligncenter" width="300"] This also a pelican[/caption] + +[caption attachment_id="44" align="aligncenter" width="300"] Yet another pelican[/caption] + +Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod +tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, +quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo +consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse +cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non +proident, sunt in culpa qui officia deserunt mollit anim id est laborum.]]> + + 176 + 2012-02-16 15:52:55 + 0000-00-00 00:00:00 + open + open + caption-on-image + publish + 0 + 0 + post + + 0 + + + _edit_last + + + A custom post in category 4 http://thisisa.test/?p=175 @@ -793,7 +838,7 @@ proident, sunt in culpa qui officia deserunt mollit anim id est laborum.]]>_edit_last - + A 2nd custom post type also in category 5 http://thisisa.test/?p=177 diff --git a/pelican/tests/default_conf.py b/pelican/tests/default_conf.py index 99f3b6cf..583c3253 100644 --- a/pelican/tests/default_conf.py +++ b/pelican/tests/default_conf.py @@ -1,43 +1,47 @@ -AUTHOR = 'Alexis Métaireau' +AUTHOR = "Alexis Métaireau" SITENAME = "Alexis' log" -SITEURL = 'http://blog.notmyidea.org' -TIMEZONE = 'UTC' +SITEURL = "http://blog.notmyidea.org" +TIMEZONE = "UTC" -GITHUB_URL = 'http://github.com/ametaireau/' +GITHUB_URL = "http://github.com/ametaireau/" DISQUS_SITENAME = "blog-notmyidea" PDF_GENERATOR = False REVERSE_CATEGORY_ORDER = True DEFAULT_PAGINATION = 2 -FEED_RSS = 'feeds/all.rss.xml' -CATEGORY_FEED_RSS = 'feeds/{slug}.rss.xml' +FEED_RSS = "feeds/all.rss.xml" +CATEGORY_FEED_RSS = "feeds/{slug}.rss.xml" -LINKS = (('Biologeek', 'http://biologeek.org'), - ('Filyb', "http://filyb.info/"), - ('Libert-fr', "http://www.libert-fr.com"), - ('N1k0', "http://prendreuncafe.com/blog/"), - ('Tarek Ziadé', "http://ziade.org/blog"), - ('Zubin Mithra', "http://zubin71.wordpress.com/"),) +LINKS = ( + ("Biologeek", "http://biologeek.org"), + ("Filyb", "http://filyb.info/"), + ("Libert-fr", "http://www.libert-fr.com"), + ("N1k0", "http://prendreuncafe.com/blog/"), + ("Tarek Ziadé", "http://ziade.org/blog"), + ("Zubin Mithra", "http://zubin71.wordpress.com/"), +) -SOCIAL = (('twitter', 'http://twitter.com/ametaireau'), - ('lastfm', 'http://lastfm.com/user/akounet'), - ('github', 'http://github.com/ametaireau'),) +SOCIAL = ( + ("twitter", "http://twitter.com/ametaireau"), + ("lastfm", "http://lastfm.com/user/akounet"), + ("github", "http://github.com/ametaireau"), +) # global metadata to all the contents -DEFAULT_METADATA = {'yeah': 'it is'} +DEFAULT_METADATA = {"yeah": "it is"} # path-specific metadata EXTRA_PATH_METADATA = { - 'extra/robots.txt': {'path': 'robots.txt'}, + "extra/robots.txt": {"path": "robots.txt"}, } # static paths will be copied without parsing their contents STATIC_PATHS = [ - 'pictures', - 'extra/robots.txt', + "pictures", + "extra/robots.txt", ] -FORMATTED_FIELDS = ['summary', 'custom_formatted_field'] +FORMATTED_FIELDS = ["summary", "custom_formatted_field"] # foobar will not be used, because it's not in caps. All configuration keys # have to be in caps diff --git a/pelican/tests/dummy_plugins/namespace_plugin/pelican/plugins/ns_plugin/__init__.py b/pelican/tests/dummy_plugins/namespace_plugin/pelican/plugins/ns_plugin/__init__.py index c514861d..1979cf09 100644 --- a/pelican/tests/dummy_plugins/namespace_plugin/pelican/plugins/ns_plugin/__init__.py +++ b/pelican/tests/dummy_plugins/namespace_plugin/pelican/plugins/ns_plugin/__init__.py @@ -1,4 +1,4 @@ -NAME = 'namespace plugin' +NAME = "namespace plugin" def register(): diff --git a/pelican/tests/output/basic/a-markdown-powered-article.html b/pelican/tests/output/basic/a-markdown-powered-article.html index ca9b62eb..0098ccac 100644 --- a/pelican/tests/output/basic/a-markdown-powered-article.html +++ b/pelican/tests/output/basic/a-markdown-powered-article.html @@ -58,10 +58,10 @@ diff --git a/pelican/tests/output/basic/archives.html b/pelican/tests/output/basic/archives.html index 93c1d5be..e3a6c7df 100644 --- a/pelican/tests/output/basic/archives.html +++ b/pelican/tests/output/basic/archives.html @@ -60,10 +60,10 @@ diff --git a/pelican/tests/output/basic/article-1.html b/pelican/tests/output/basic/article-1.html index 4dee2eb6..961ad390 100644 --- a/pelican/tests/output/basic/article-1.html +++ b/pelican/tests/output/basic/article-1.html @@ -57,10 +57,10 @@ diff --git a/pelican/tests/output/basic/article-2.html b/pelican/tests/output/basic/article-2.html index 7e5995c0..e5389d35 100644 --- a/pelican/tests/output/basic/article-2.html +++ b/pelican/tests/output/basic/article-2.html @@ -57,10 +57,10 @@ diff --git a/pelican/tests/output/basic/article-3.html b/pelican/tests/output/basic/article-3.html index b0b8ed1b..d23e5da2 100644 --- a/pelican/tests/output/basic/article-3.html +++ b/pelican/tests/output/basic/article-3.html @@ -57,10 +57,10 @@ diff --git a/pelican/tests/output/basic/author/alexis-metaireau.html b/pelican/tests/output/basic/author/alexis-metaireau.html index 87eaa461..12e05ec8 100644 --- a/pelican/tests/output/basic/author/alexis-metaireau.html +++ b/pelican/tests/output/basic/author/alexis-metaireau.html @@ -102,10 +102,10 @@ YEAH !

diff --git a/pelican/tests/output/basic/authors.html b/pelican/tests/output/basic/authors.html index 937a3085..cff1360b 100644 --- a/pelican/tests/output/basic/authors.html +++ b/pelican/tests/output/basic/authors.html @@ -42,10 +42,10 @@ diff --git a/pelican/tests/output/basic/categories.html b/pelican/tests/output/basic/categories.html index 96a61c73..cc54f4a3 100644 --- a/pelican/tests/output/basic/categories.html +++ b/pelican/tests/output/basic/categories.html @@ -45,10 +45,10 @@ diff --git a/pelican/tests/output/basic/category/bar.html b/pelican/tests/output/basic/category/bar.html index 65b7b04e..1f9c0d8d 100644 --- a/pelican/tests/output/basic/category/bar.html +++ b/pelican/tests/output/basic/category/bar.html @@ -58,10 +58,10 @@ YEAH !

diff --git a/pelican/tests/output/basic/category/cat1.html b/pelican/tests/output/basic/category/cat1.html index 62fb034c..ca47821b 100644 --- a/pelican/tests/output/basic/category/cat1.html +++ b/pelican/tests/output/basic/category/cat1.html @@ -115,10 +115,10 @@ diff --git a/pelican/tests/output/basic/category/misc.html b/pelican/tests/output/basic/category/misc.html index ed06ebc0..58490001 100644 --- a/pelican/tests/output/basic/category/misc.html +++ b/pelican/tests/output/basic/category/misc.html @@ -126,10 +126,10 @@ pelican.conf, it will …

diff --git a/pelican/tests/output/basic/category/yeah.html b/pelican/tests/output/basic/category/yeah.html index 9d2f30a2..815d42e4 100644 --- a/pelican/tests/output/basic/category/yeah.html +++ b/pelican/tests/output/basic/category/yeah.html @@ -66,10 +66,10 @@ diff --git a/pelican/tests/output/basic/drafts/a-draft-article-without-date.html b/pelican/tests/output/basic/drafts/a-draft-article-without-date.html index bc30dabd..5a2d367b 100644 --- a/pelican/tests/output/basic/drafts/a-draft-article-without-date.html +++ b/pelican/tests/output/basic/drafts/a-draft-article-without-date.html @@ -58,10 +58,10 @@ listed anywhere else.

diff --git a/pelican/tests/output/basic/drafts/a-draft-article.html b/pelican/tests/output/basic/drafts/a-draft-article.html index 4abd4946..a0bed241 100644 --- a/pelican/tests/output/basic/drafts/a-draft-article.html +++ b/pelican/tests/output/basic/drafts/a-draft-article.html @@ -58,10 +58,10 @@ listed anywhere else.

diff --git a/pelican/tests/output/basic/filename_metadata-example.html b/pelican/tests/output/basic/filename_metadata-example.html index ae19ceb1..f3ae9cea 100644 --- a/pelican/tests/output/basic/filename_metadata-example.html +++ b/pelican/tests/output/basic/filename_metadata-example.html @@ -57,10 +57,10 @@ diff --git a/pelican/tests/output/basic/index.html b/pelican/tests/output/basic/index.html index 711dd2bd..fd334d38 100644 --- a/pelican/tests/output/basic/index.html +++ b/pelican/tests/output/basic/index.html @@ -265,10 +265,10 @@ pelican.conf, it will …

diff --git a/pelican/tests/output/basic/oh-yeah-fr.html b/pelican/tests/output/basic/oh-yeah-fr.html index 183754ff..3fdfeae9 100644 --- a/pelican/tests/output/basic/oh-yeah-fr.html +++ b/pelican/tests/output/basic/oh-yeah-fr.html @@ -61,10 +61,10 @@ Translations: diff --git a/pelican/tests/output/basic/oh-yeah.html b/pelican/tests/output/basic/oh-yeah.html index f63302d2..f8d3d227 100644 --- a/pelican/tests/output/basic/oh-yeah.html +++ b/pelican/tests/output/basic/oh-yeah.html @@ -69,10 +69,10 @@ YEAH !

diff --git a/pelican/tests/output/basic/override/index.html b/pelican/tests/output/basic/override/index.html index 1d5531d7..a74d802f 100644 --- a/pelican/tests/output/basic/override/index.html +++ b/pelican/tests/output/basic/override/index.html @@ -41,10 +41,10 @@ at a custom location.

diff --git a/pelican/tests/output/basic/pages/this-is-a-test-hidden-page.html b/pelican/tests/output/basic/pages/this-is-a-test-hidden-page.html index 8c14e354..1c836201 100644 --- a/pelican/tests/output/basic/pages/this-is-a-test-hidden-page.html +++ b/pelican/tests/output/basic/pages/this-is-a-test-hidden-page.html @@ -41,10 +41,10 @@ Anyone can see this page but it's not linked to anywhere!

diff --git a/pelican/tests/output/basic/pages/this-is-a-test-page.html b/pelican/tests/output/basic/pages/this-is-a-test-page.html index 1fdb846f..372eff76 100644 --- a/pelican/tests/output/basic/pages/this-is-a-test-page.html +++ b/pelican/tests/output/basic/pages/this-is-a-test-page.html @@ -42,10 +42,10 @@ diff --git a/pelican/tests/output/basic/second-article-fr.html b/pelican/tests/output/basic/second-article-fr.html index b49c4517..514c5585 100644 --- a/pelican/tests/output/basic/second-article-fr.html +++ b/pelican/tests/output/basic/second-article-fr.html @@ -61,10 +61,10 @@ diff --git a/pelican/tests/output/basic/second-article.html b/pelican/tests/output/basic/second-article.html index 2b3bca88..365f99cd 100644 --- a/pelican/tests/output/basic/second-article.html +++ b/pelican/tests/output/basic/second-article.html @@ -61,10 +61,10 @@ diff --git a/pelican/tests/output/basic/tag/bar.html b/pelican/tests/output/basic/tag/bar.html index a78c08f9..047d772a 100644 --- a/pelican/tests/output/basic/tag/bar.html +++ b/pelican/tests/output/basic/tag/bar.html @@ -114,10 +114,10 @@ YEAH !

diff --git a/pelican/tests/output/basic/tag/baz.html b/pelican/tests/output/basic/tag/baz.html index 0b7d04ab..51518620 100644 --- a/pelican/tests/output/basic/tag/baz.html +++ b/pelican/tests/output/basic/tag/baz.html @@ -57,10 +57,10 @@ diff --git a/pelican/tests/output/basic/tag/foo.html b/pelican/tests/output/basic/tag/foo.html index 6ec55bee..0214cf26 100644 --- a/pelican/tests/output/basic/tag/foo.html +++ b/pelican/tests/output/basic/tag/foo.html @@ -84,10 +84,10 @@ as well as inline markup.

diff --git a/pelican/tests/output/basic/tag/foobar.html b/pelican/tests/output/basic/tag/foobar.html index def1d0c6..ab07e87f 100644 --- a/pelican/tests/output/basic/tag/foobar.html +++ b/pelican/tests/output/basic/tag/foobar.html @@ -66,10 +66,10 @@ diff --git a/pelican/tests/output/basic/tag/oh.html b/pelican/tests/output/basic/tag/oh.html index 11b06e61..f3af2a2f 100644 --- a/pelican/tests/output/basic/tag/oh.html +++ b/pelican/tests/output/basic/tag/oh.html @@ -40,10 +40,10 @@ diff --git a/pelican/tests/output/basic/tag/yeah.html b/pelican/tests/output/basic/tag/yeah.html index 565a3a25..f04affa8 100644 --- a/pelican/tests/output/basic/tag/yeah.html +++ b/pelican/tests/output/basic/tag/yeah.html @@ -58,10 +58,10 @@ YEAH !

diff --git a/pelican/tests/output/basic/tags.html b/pelican/tests/output/basic/tags.html index 6dce2632..db5d6634 100644 --- a/pelican/tests/output/basic/tags.html +++ b/pelican/tests/output/basic/tags.html @@ -47,10 +47,10 @@ diff --git a/pelican/tests/output/basic/theme/css/main.css b/pelican/tests/output/basic/theme/css/main.css index a4aa51a1..49de03d9 100644 --- a/pelican/tests/output/basic/theme/css/main.css +++ b/pelican/tests/output/basic/theme/css/main.css @@ -322,39 +322,6 @@ div.figure p.caption, figure p.caption { /* margin provided by figure */ max-width: 175px; } - #extras div[class='social'] a { - background-repeat: no-repeat; - background-position: 3px 6px; - padding-left: 25px; - } - - /* Icons */ - .social a[href*='about.me'] {background-image: url('../images/icons/aboutme.png');} - .social a[href*='bitbucket.org'] {background-image: url('../images/icons/bitbucket.png');} - .social a[href*='delicious.com'] {background-image: url('../images/icons/delicious.png');} - .social a[href*='facebook.com'] {background-image: url('../images/icons/facebook.png');} - .social a[href*='gitorious.org'] {background-image: url('../images/icons/gitorious.png');} - .social a[href*='github.com'], - .social a[href*='git.io'] { - background-image: url('../images/icons/github.png'); - background-size: 16px 16px; - } - .social a[href*='gittip.com'] {background-image: url('../images/icons/gittip.png');} - .social a[href*='plus.google.com'] {background-image: url('../images/icons/google-plus.png');} - .social a[href*='groups.google.com'] {background-image: url('../images/icons/google-groups.png');} - .social a[href*='news.ycombinator.com'], - .social a[href*='hackernewsers.com'] {background-image: url('../images/icons/hackernews.png');} - .social a[href*='last.fm'], .social a[href*='lastfm.'] {background-image: url('../images/icons/lastfm.png');} - .social a[href*='linkedin.com'] {background-image: url('../images/icons/linkedin.png');} - .social a[href*='reddit.com'] {background-image: url('../images/icons/reddit.png');} - .social a[type$='atom+xml'], .social a[type$='rss+xml'] {background-image: url('../images/icons/rss.png');} - .social a[href*='slideshare.net'] {background-image: url('../images/icons/slideshare.png');} - .social a[href*='speakerdeck.com'] {background-image: url('../images/icons/speakerdeck.png');} - .social a[href*='stackoverflow.com'] {background-image: url('../images/icons/stackoverflow.png');} - .social a[href*='twitter.com'] {background-image: url('../images/icons/twitter.png');} - .social a[href*='vimeo.com'] {background-image: url('../images/icons/vimeo.png');} - .social a[href*='youtube.com'] {background-image: url('../images/icons/youtube.png');} - /* About *****************/ diff --git a/pelican/tests/output/basic/theme/fonts/Yanone_Kaffeesatz_LICENSE.txt b/pelican/tests/output/basic/theme/fonts/Yanone_Kaffeesatz_LICENSE.txt new file mode 100644 index 00000000..309fd710 --- /dev/null +++ b/pelican/tests/output/basic/theme/fonts/Yanone_Kaffeesatz_LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2010 The Yanone Kaffeesatz Project Authors (https://github.com/alexeiva/yanone-kaffeesatz) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/pelican/tests/output/basic/theme/images/icons/aboutme.png b/pelican/tests/output/basic/theme/images/icons/aboutme.png deleted file mode 100644 index 600110f2..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/aboutme.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/bitbucket.png b/pelican/tests/output/basic/theme/images/icons/bitbucket.png deleted file mode 100644 index 277a7dfb..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/bitbucket.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/delicious.png b/pelican/tests/output/basic/theme/images/icons/delicious.png deleted file mode 100644 index 34868c5c..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/delicious.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/facebook.png b/pelican/tests/output/basic/theme/images/icons/facebook.png deleted file mode 100644 index 1d8a4327..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/facebook.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/github.png b/pelican/tests/output/basic/theme/images/icons/github.png deleted file mode 100644 index 5d9109de..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/github.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/gitorious.png b/pelican/tests/output/basic/theme/images/icons/gitorious.png deleted file mode 100644 index a6705d0f..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/gitorious.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/gittip.png b/pelican/tests/output/basic/theme/images/icons/gittip.png deleted file mode 100644 index b9f67aaa..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/gittip.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/google-groups.png b/pelican/tests/output/basic/theme/images/icons/google-groups.png deleted file mode 100644 index bbd0a0fd..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/google-groups.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/google-plus.png b/pelican/tests/output/basic/theme/images/icons/google-plus.png deleted file mode 100644 index f8553d45..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/google-plus.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/hackernews.png b/pelican/tests/output/basic/theme/images/icons/hackernews.png deleted file mode 100644 index 8e05e3ee..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/hackernews.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/lastfm.png b/pelican/tests/output/basic/theme/images/icons/lastfm.png deleted file mode 100644 index 2eedd2da..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/lastfm.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/linkedin.png b/pelican/tests/output/basic/theme/images/icons/linkedin.png deleted file mode 100644 index 06a88016..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/linkedin.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/reddit.png b/pelican/tests/output/basic/theme/images/icons/reddit.png deleted file mode 100644 index d826d3e7..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/reddit.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/rss.png b/pelican/tests/output/basic/theme/images/icons/rss.png deleted file mode 100644 index 12448f53..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/rss.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/slideshare.png b/pelican/tests/output/basic/theme/images/icons/slideshare.png deleted file mode 100644 index 9cbe8588..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/slideshare.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/speakerdeck.png b/pelican/tests/output/basic/theme/images/icons/speakerdeck.png deleted file mode 100644 index 7281ec48..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/speakerdeck.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/stackoverflow.png b/pelican/tests/output/basic/theme/images/icons/stackoverflow.png deleted file mode 100644 index 3c6862e9..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/stackoverflow.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/twitter.png b/pelican/tests/output/basic/theme/images/icons/twitter.png deleted file mode 100644 index cef1cef9..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/twitter.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/vimeo.png b/pelican/tests/output/basic/theme/images/icons/vimeo.png deleted file mode 100644 index 4b9d7212..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/vimeo.png and /dev/null differ diff --git a/pelican/tests/output/basic/theme/images/icons/youtube.png b/pelican/tests/output/basic/theme/images/icons/youtube.png deleted file mode 100644 index e334e68e..00000000 Binary files a/pelican/tests/output/basic/theme/images/icons/youtube.png and /dev/null differ diff --git a/pelican/tests/output/basic/this-is-a-super-article.html b/pelican/tests/output/basic/this-is-a-super-article.html index 8681bb1b..6ac68664 100644 --- a/pelican/tests/output/basic/this-is-a-super-article.html +++ b/pelican/tests/output/basic/this-is-a-super-article.html @@ -75,10 +75,10 @@ diff --git a/pelican/tests/output/basic/unbelievable.html b/pelican/tests/output/basic/unbelievable.html index 8a8b4f5a..d87c31ea 100644 --- a/pelican/tests/output/basic/unbelievable.html +++ b/pelican/tests/output/basic/unbelievable.html @@ -89,10 +89,10 @@ pelican.conf, it will have nothing in default.

diff --git a/pelican/tests/output/custom/a-markdown-powered-article.html b/pelican/tests/output/custom/a-markdown-powered-article.html index 36fb242f..422421d8 100644 --- a/pelican/tests/output/custom/a-markdown-powered-article.html +++ b/pelican/tests/output/custom/a-markdown-powered-article.html @@ -95,10 +95,10 @@ + {% endif %} diff --git a/pelican/themes/simple/templates/archives.html b/pelican/themes/simple/templates/archives.html index cd129507..c7fb2127 100644 --- a/pelican/themes/simple/templates/archives.html +++ b/pelican/themes/simple/templates/archives.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}{{ SITENAME }} - Archives{% endblock %} +{% block title %}{{ SITENAME|striptags }} - Archives{% endblock %} {% block content %} -

Archives for {{ SITENAME }}

+

Archives for {{ SITENAME }}

{% for article in dates %} diff --git a/pelican/themes/simple/templates/article.html b/pelican/themes/simple/templates/article.html index 6dd0d967..07e5534d 100644 --- a/pelican/themes/simple/templates/article.html +++ b/pelican/themes/simple/templates/article.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block html_lang %}{{ article.lang }}{% endblock %} -{% block title %}{{ SITENAME }} - {{ article.title|striptags }}{% endblock %} +{% block title %}{{ SITENAME|striptags }} - {{ article.title|striptags }}{% endblock %} {% block head %} {{ super() }} @@ -22,44 +22,44 @@ {% endblock %} {% block content %} +
-

+

{{ article.title }}

+ title="Permalink to {{ article.title|striptags }}">{{ article.title }} {% import 'translations.html' as translations with context %} {{ translations.translations_for(article) }}
-
-
{% endblock %} diff --git a/pelican/themes/simple/templates/author.html b/pelican/themes/simple/templates/author.html index 79d22c7d..9b30dfe2 100644 --- a/pelican/themes/simple/templates/author.html +++ b/pelican/themes/simple/templates/author.html @@ -1,8 +1,7 @@ {% extends "index.html" %} -{% block title %}{{ SITENAME }} - Articles by {{ author }}{% endblock %} +{% block title %}{{ SITENAME|striptags }} - Articles by {{ author }}{% endblock %} {% block content_title %} -

Articles by {{ author }}

+

Articles by {{ author }}

{% endblock %} - diff --git a/pelican/themes/simple/templates/authors.html b/pelican/themes/simple/templates/authors.html index 9aee5db4..01b4f6f1 100644 --- a/pelican/themes/simple/templates/authors.html +++ b/pelican/themes/simple/templates/authors.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}{{ SITENAME }} - Authors{% endblock %} +{% block title %}{{ SITENAME|striptags }} - Authors{% endblock %} {% block content %} -

Authors on {{ SITENAME }}

+

Authors on {{ SITENAME }}

    {% for author, articles in authors|sort %}
  • {{ author }} ({{ articles|count }})
  • diff --git a/pelican/themes/simple/templates/base.html b/pelican/themes/simple/templates/base.html index 1d8ae843..e006cba1 100644 --- a/pelican/themes/simple/templates/base.html +++ b/pelican/themes/simple/templates/base.html @@ -2,65 +2,71 @@ {% block head %} - {% block title %}{{ SITENAME }}{% endblock title %} + {% block title %}{{ SITENAME|striptags }}{% endblock title %} + {% if SITESUBTITLE %} + + {% endif %} + {% if STYLESHEET_URL %} + + {% endif %} {% if FEED_ALL_ATOM %} - + {% endif %} {% if FEED_ALL_RSS %} - + {% endif %} {% if FEED_ATOM %} - + {% endif %} {% if FEED_RSS %} - + {% endif %} {% if CATEGORY_FEED_ATOM and category %} - + {% endif %} {% if CATEGORY_FEED_RSS and category %} - + {% endif %} {% if TAG_FEED_ATOM and tag %} - + {% endif %} {% if TAG_FEED_RSS and tag %} - + {% endif %} {% endblock head %}
    -

    {{ SITENAME }}{% if SITESUBTITLE %} {{ SITESUBTITLE }}{% endif %}

    -
    +

    {{ SITENAME }}

    {% if SITESUBTITLE %}

    {{ SITESUBTITLE }}

    {% endif %}
    +
    {% block content %} {% endblock %}
    -
    - Proudly powered by Pelican, - which takes great advantage of Python. -
    +
    + Proudly powered by Pelican, + which takes great advantage of Python. +
    diff --git a/pelican/themes/simple/templates/categories.html b/pelican/themes/simple/templates/categories.html index 7999de43..7da19fb4 100644 --- a/pelican/themes/simple/templates/categories.html +++ b/pelican/themes/simple/templates/categories.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}{{ SITENAME }} - Categories{% endblock %} +{% block title %}{{ SITENAME|striptags }} - Categories{% endblock %} {% block content %} -

    Categories on {{ SITENAME }}

    +

    Categories on {{ SITENAME }}

      {% for category, articles in categories|sort %}
    • {{ category }} ({{ articles|count }})
    • diff --git a/pelican/themes/simple/templates/category.html b/pelican/themes/simple/templates/category.html index d73f6e31..16525fb2 100644 --- a/pelican/themes/simple/templates/category.html +++ b/pelican/themes/simple/templates/category.html @@ -1,8 +1,7 @@ {% extends "index.html" %} -{% block title %}{{ SITENAME }} - {{ category }} category{% endblock %} +{% block title %}{{ SITENAME|striptags }} - {{ category }} category{% endblock %} {% block content_title %} -

      Articles in the {{ category }} category

      +

      Articles in the {{ category }} category

      {% endblock %} - diff --git a/pelican/themes/simple/templates/index.html b/pelican/themes/simple/templates/index.html index ab4bc345..c9837b54 100644 --- a/pelican/themes/simple/templates/index.html +++ b/pelican/themes/simple/templates/index.html @@ -1,28 +1,27 @@ {% extends "base.html" %} {% block content %} -
      {% block content_title %}

      All articles

      {% endblock %} -
        + {% for article in articles_page.object_list %} -
      1. + + {% endfor %} -
      + {% if articles_page.has_other_pages() %} {% include 'pagination.html' %} {% endif %} -
      + {% endblock content %} diff --git a/pelican/themes/simple/templates/page.html b/pelican/themes/simple/templates/page.html index 33344eac..38452d1d 100644 --- a/pelican/themes/simple/templates/page.html +++ b/pelican/themes/simple/templates/page.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block html_lang %}{{ page.lang }}{% endblock %} -{% block title %}{{ SITENAME }} - {{ page.title|striptags }}{%endblock%} +{% block title %}{{ SITENAME|striptags }} - {{ page.title|striptags }}{%endblock%} {% block head %} {{ super() }} @@ -13,15 +13,21 @@ {% endblock %} {% block content %} -

      {{ page.title }}

      +
      +
      +

      {{ page.title }}

      +
      {% import 'translations.html' as translations with context %} {{ translations.translations_for(page) }} {{ page.content }} {% if page.modified %} +

      Last updated: {{ page.locale_modified }}

      +
      {% endif %} +
      {% endblock %} diff --git a/pelican/themes/simple/templates/pagination.html b/pelican/themes/simple/templates/pagination.html index 588f130c..45e7f167 100644 --- a/pelican/themes/simple/templates/pagination.html +++ b/pelican/themes/simple/templates/pagination.html @@ -1,15 +1,17 @@ {% if DEFAULT_PAGINATION %} {% set first_page = articles_paginator.page(1) %} {% set last_page = articles_paginator.page(articles_paginator.num_pages) %} -

      +

      {% endif %} diff --git a/pelican/themes/simple/templates/period_archives.html b/pelican/themes/simple/templates/period_archives.html index e1ddf626..595def50 100644 --- a/pelican/themes/simple/templates/period_archives.html +++ b/pelican/themes/simple/templates/period_archives.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}{{ SITENAME }} - {{ period | reverse | join(' ') }} archives{% endblock %} +{% block title %}{{ SITENAME|striptags }} - {{ period | reverse | join(' ') }} archives{% endblock %} {% block content %} -

      Archives for {{ period | reverse | join(' ') }}

      +

      Archives for {{ period | reverse | join(' ') }}

      {% for article in dates %} diff --git a/pelican/themes/simple/templates/tag.html b/pelican/themes/simple/templates/tag.html index 93878134..f9b71f48 100644 --- a/pelican/themes/simple/templates/tag.html +++ b/pelican/themes/simple/templates/tag.html @@ -1,7 +1,7 @@ {% extends "index.html" %} -{% block title %}{{ SITENAME }} - {{ tag }} tag{% endblock %} +{% block title %}{{ SITENAME|striptags }} - {{ tag }} tag{% endblock %} {% block content_title %} -

      Articles tagged with {{ tag }}

      +

      Articles tagged with {{ tag }}

      {% endblock %} diff --git a/pelican/themes/simple/templates/tags.html b/pelican/themes/simple/templates/tags.html index b90b0ac3..6b5c6b1c 100644 --- a/pelican/themes/simple/templates/tags.html +++ b/pelican/themes/simple/templates/tags.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}{{ SITENAME }} - Tags{% endblock %} +{% block title %}{{ SITENAME|striptags }} - Tags{% endblock %} {% block content %} -

      Tags for {{ SITENAME }}

      +

      Tags for {{ SITENAME }}

        {% for tag, articles in tags|sort %}
      • {{ tag }} ({{ articles|count }})
      • diff --git a/pelican/tools/pelican_import.py b/pelican/tools/pelican_import.py index 7833ebbe..681a5c45 100755 --- a/pelican/tools/pelican_import.py +++ b/pelican/tools/pelican_import.py @@ -1,11 +1,13 @@ #!/usr/bin/env python import argparse +import datetime import logging import os import re import subprocess import sys +import tempfile import time from collections import defaultdict from html import unescape @@ -39,74 +41,76 @@ def decode_wp_content(content, br=True): if start == -1: content = content + pre_part continue - name = "
        ".format(pre_index)
        +            name = f"
        "
                     pre_tags[name] = pre_part[start:] + ""
                     content = content + pre_part[0:start] + name
                     pre_index += 1
                 content = content + last_pre
         
        -    content = re.sub(r'
        \s*
        ', "\n\n", content) - allblocks = ('(?:table|thead|tfoot|caption|col|colgroup|tbody|tr|' - 'td|th|div|dl|dd|dt|ul|ol|li|pre|select|option|form|' - 'map|area|blockquote|address|math|style|p|h[1-6]|hr|' - 'fieldset|noscript|samp|legend|section|article|aside|' - 'hgroup|header|footer|nav|figure|figcaption|details|' - 'menu|summary)') - content = re.sub(r'(<' + allblocks + r'[^>]*>)', "\n\\1", content) - content = re.sub(r'()', "\\1\n\n", content) + content = re.sub(r"
        \s*
        ", "\n\n", content) + allblocks = ( + "(?:table|thead|tfoot|caption|col|colgroup|tbody|tr|" + "td|th|div|dl|dd|dt|ul|ol|li|pre|select|option|form|" + "map|area|blockquote|address|math|style|p|h[1-6]|hr|" + "fieldset|noscript|samp|legend|section|article|aside|" + "hgroup|header|footer|nav|figure|figcaption|details|" + "menu|summary)" + ) + content = re.sub(r"(<" + allblocks + r"[^>]*>)", "\n\\1", content) + content = re.sub(r"()", "\\1\n\n", content) # content = content.replace("\r\n", "\n") if " inside object/embed - content = re.sub(r'\s*]*)>\s*', "", content) - content = re.sub(r'\s*\s*', '', content) + content = re.sub(r"\s*]*)>\s*", "", content) + content = re.sub(r"\s*\s*", "", content) # content = re.sub(r'/\n\n+/', '\n\n', content) - pgraphs = filter(lambda s: s != "", re.split(r'\n\s*\n', content)) + pgraphs = filter(lambda s: s != "", re.split(r"\n\s*\n", content)) content = "" for p in pgraphs: content = content + "

        " + p.strip() + "

        \n" # under certain strange conditions it could create # a P of entirely whitespace - content = re.sub(r'

        \s*

        ', '', content) - content = re.sub( - r'

        ([^<]+)', - "

        \\1

        ", - content) + content = re.sub(r"

        \s*

        ", "", content) + content = re.sub(r"

        ([^<]+)", "

        \\1

        ", content) # don't wrap tags - content = re.sub( - r'

        \s*(]*>)\s*

        ', - "\\1", - content) + content = re.sub(r"

        \s*(]*>)\s*

        ", "\\1", content) # problem with nested lists - content = re.sub(r'

        (', "\\1", content) - content = re.sub(r'

        ]*)>', "

        ", content) - content = content.replace('

        ', '

        ') - content = re.sub(r'

        \s*(]*>)', "\\1", content) - content = re.sub(r'(]*>)\s*

        ', "\\1", content) + content = re.sub(r"

        (", "\\1", content) + content = re.sub(r"

        ]*)>", "

        ", content) + content = content.replace("

        ", "

        ") + content = re.sub(r"

        \s*(]*>)", "\\1", content) + content = re.sub(r"(]*>)\s*

        ", "\\1", content) if br: + def _preserve_newline(match): return match.group(0).replace("\n", "") - content = re.sub( - r'/<(script|style).*?<\/\\1>/s', - _preserve_newline, - content) + + content = re.sub(r"/<(script|style).*?<\/\\1>/s", _preserve_newline, content) # optionally make line breaks - content = re.sub(r'(?)\s*\n', "
        \n", content) + content = re.sub(r"(?)\s*\n", "
        \n", content) content = content.replace("", "\n") + content = re.sub(r"(]*>)\s*
        ", "\\1", content) content = re.sub( - r'(]*>)\s*
        ', "\\1", - content) - content = re.sub( - r'
        (\s*]*>)', - '\\1', - content) - content = re.sub(r'\n

        ', "

        ", content) + r"
        (\s*]*>)", "\\1", content + ) + content = re.sub(r"\n

        ", "

        ", content) if pre_tags: + def _multi_replace(dic, string): - pattern = r'|'.join(map(re.escape, dic.keys())) + pattern = r"|".join(map(re.escape, dic.keys())) return re.sub(pattern, lambda m: dic[m.group()], string) + content = _multi_replace(pre_tags, content) + # convert [caption] tags into
        + content = re.sub( + r"\[caption(?:.*?)(?:caption=\"(.*?)\")?\]" + r"((?:\)?(?:\)(?:\<\/a\>)?)\s?(.*?)\[\/caption\]", + r"
        \n\2\n
        \1\3
        \n
        ", + content, + ) + return content @@ -115,10 +119,12 @@ def xml_to_soup(xml): try: from bs4 import BeautifulSoup except ImportError: - error = ('Missing dependency "BeautifulSoup4" and "lxml" required to ' - 'import XML files.') + error = ( + 'Missing dependency "BeautifulSoup4" and "lxml" required to ' + "import XML files." + ) sys.exit(error) - with open(xml, encoding='utf-8') as infile: + with open(xml, encoding="utf-8") as infile: xmlfile = infile.read() soup = BeautifulSoup(xmlfile, "xml") return soup @@ -135,111 +141,125 @@ def wp2fields(xml, wp_custpost=False): """Opens a wordpress XML file, and yield Pelican fields""" soup = xml_to_soup(xml) - items = soup.rss.channel.findAll('item') + items = soup.rss.channel.findAll("item") for item in items: - - if item.find('status').string in ["publish", "draft"]: - + if item.find("status").string in ["publish", "draft"]: try: # Use HTMLParser due to issues with BeautifulSoup 3 title = unescape(item.title.contents[0]) except IndexError: - title = 'No title [%s]' % item.find('post_name').string + title = "No title [%s]" % item.find("post_name").string logger.warning('Post "%s" is lacking a proper title', title) - post_name = item.find('post_name').string - post_id = item.find('post_id').string + post_name = item.find("post_name").string + post_id = item.find("post_id").string filename = get_filename(post_name, post_id) - content = item.find('encoded').string - raw_date = item.find('post_date').string - if raw_date == '0000-00-00 00:00:00': + content = item.find("encoded").string + raw_date = item.find("post_date").string + if raw_date == "0000-00-00 00:00:00": date = None else: - date_object = SafeDatetime.strptime( - raw_date, '%Y-%m-%d %H:%M:%S') - date = date_object.strftime('%Y-%m-%d %H:%M') - author = item.find('creator').string + date_object = SafeDatetime.strptime(raw_date, "%Y-%m-%d %H:%M:%S") + date = date_object.strftime("%Y-%m-%d %H:%M") + author = item.find("creator").string - categories = [cat.string for cat - in item.findAll('category', {'domain': 'category'})] + categories = [ + cat.string for cat in item.findAll("category", {"domain": "category"}) + ] - tags = [tag.string for tag - in item.findAll('category', {'domain': 'post_tag'})] + tags = [ + tag.string for tag in item.findAll("category", {"domain": "post_tag"}) + ] # To publish a post the status should be 'published' - status = 'published' if item.find('status').string == "publish" \ - else item.find('status').string + status = ( + "published" + if item.find("status").string == "publish" + else item.find("status").string + ) - kind = 'article' - post_type = item.find('post_type').string - if post_type == 'page': - kind = 'page' + kind = "article" + post_type = item.find("post_type").string + if post_type == "page": + kind = "page" elif wp_custpost: - if post_type == 'post': + if post_type == "post": pass # Old behaviour was to name everything not a page as an # article.Theoretically all attachments have status == inherit # so no attachments should be here. But this statement is to # maintain existing behaviour in case that doesn't hold true. - elif post_type == 'attachment': + elif post_type == "attachment": pass else: kind = post_type - yield (title, content, filename, date, author, categories, - tags, status, kind, 'wp-html') + yield ( + title, + content, + filename, + date, + author, + categories, + tags, + status, + kind, + "wp-html", + ) def blogger2fields(xml): """Opens a blogger XML file, and yield Pelican fields""" soup = xml_to_soup(xml) - entries = soup.feed.findAll('entry') + entries = soup.feed.findAll("entry") for entry in entries: raw_kind = entry.find( - 'category', {'scheme': 'http://schemas.google.com/g/2005#kind'} - ).get('term') - if raw_kind == 'http://schemas.google.com/blogger/2008/kind#post': - kind = 'article' - elif raw_kind == 'http://schemas.google.com/blogger/2008/kind#comment': - kind = 'comment' - elif raw_kind == 'http://schemas.google.com/blogger/2008/kind#page': - kind = 'page' + "category", {"scheme": "http://schemas.google.com/g/2005#kind"} + ).get("term") + if raw_kind == "http://schemas.google.com/blogger/2008/kind#post": + kind = "article" + elif raw_kind == "http://schemas.google.com/blogger/2008/kind#comment": + kind = "comment" + elif raw_kind == "http://schemas.google.com/blogger/2008/kind#page": + kind = "page" else: continue try: - assert kind != 'comment' - filename = entry.find('link', {'rel': 'alternate'})['href'] + assert kind != "comment" + filename = entry.find("link", {"rel": "alternate"})["href"] filename = os.path.splitext(os.path.basename(filename))[0] except (AssertionError, TypeError, KeyError): - filename = entry.find('id').string.split('.')[-1] + filename = entry.find("id").string.split(".")[-1] - title = entry.find('title').string or '' + title = entry.find("title").string or "" - content = entry.find('content').string - raw_date = entry.find('published').string - if hasattr(SafeDatetime, 'fromisoformat'): + content = entry.find("content").string + raw_date = entry.find("published").string + if hasattr(SafeDatetime, "fromisoformat"): date_object = SafeDatetime.fromisoformat(raw_date) else: - date_object = SafeDatetime.strptime( - raw_date[:23], '%Y-%m-%dT%H:%M:%S.%f') - date = date_object.strftime('%Y-%m-%d %H:%M') - author = entry.find('author').find('name').string + date_object = SafeDatetime.strptime(raw_date[:23], "%Y-%m-%dT%H:%M:%S.%f") + date = date_object.strftime("%Y-%m-%d %H:%M") + author = entry.find("author").find("name").string # blogger posts only have tags, no category - tags = [tag.get('term') for tag in entry.findAll( - 'category', {'scheme': 'http://www.blogger.com/atom/ns#'})] + tags = [ + tag.get("term") + for tag in entry.findAll( + "category", {"scheme": "http://www.blogger.com/atom/ns#"} + ) + ] # Drafts have yes - status = 'published' + status = "published" try: - if entry.find('control').find('draft').string == 'yes': - status = 'draft' + if entry.find("control").find("draft").string == "yes": + status = "draft" except AttributeError: pass - yield (title, content, filename, date, author, None, tags, status, - kind, 'html') + yield (title, content, filename, date, author, None, tags, status, kind, "html") def dc2fields(file): @@ -247,9 +267,11 @@ def dc2fields(file): try: from bs4 import BeautifulSoup except ImportError: - error = ('Missing dependency ' - '"BeautifulSoup4" and "lxml" required ' - 'to import Dotclear files.') + error = ( + "Missing dependency " + '"BeautifulSoup4" and "lxml" required ' + "to import Dotclear files." + ) sys.exit(error) in_cat = False @@ -257,15 +279,14 @@ def dc2fields(file): category_list = {} posts = [] - with open(file, encoding='utf-8') as f: - + with open(file, encoding="utf-8") as f: for line in f: # remove final \n line = line[:-1] - if line.startswith('[category'): + if line.startswith("[category"): in_cat = True - elif line.startswith('[post'): + elif line.startswith("[post"): in_post = True elif in_cat: fields = line.split('","') @@ -285,7 +306,7 @@ def dc2fields(file): print("%i posts read." % len(posts)) - subs = DEFAULT_CONFIG['SLUG_REGEX_SUBSTITUTIONS'] + subs = DEFAULT_CONFIG["SLUG_REGEX_SUBSTITUTIONS"] for post in posts: fields = post.split('","') @@ -320,44 +341,39 @@ def dc2fields(file): # redirect_url = fields[28][:-1] # remove seconds - post_creadt = ':'.join(post_creadt.split(':')[0:2]) + post_creadt = ":".join(post_creadt.split(":")[0:2]) - author = '' + author = "" categories = [] tags = [] if cat_id: - categories = [category_list[id].strip() for id - in cat_id.split(',')] + categories = [category_list[id].strip() for id in cat_id.split(",")] # Get tags related to a post - tag = (post_meta.replace('{', '') - .replace('}', '') - .replace('a:1:s:3:\\"tag\\";a:', '') - .replace('a:0:', '')) + tag = ( + post_meta.replace("{", "") + .replace("}", "") + .replace('a:1:s:3:\\"tag\\";a:', "") + .replace("a:0:", "") + ) if len(tag) > 1: if int(len(tag[:1])) == 1: newtag = tag.split('"')[1] tags.append( - BeautifulSoup( - newtag, - 'xml' - ) + BeautifulSoup(newtag, "xml") # bs4 always outputs UTF-8 - .decode('utf-8') + .decode("utf-8") ) else: i = 1 j = 1 - while (i <= int(tag[:1])): - newtag = tag.split('"')[j].replace('\\', '') + while i <= int(tag[:1]): + newtag = tag.split('"')[j].replace("\\", "") tags.append( - BeautifulSoup( - newtag, - 'xml' - ) + BeautifulSoup(newtag, "xml") # bs4 always outputs UTF-8 - .decode('utf-8') + .decode("utf-8") ) i = i + 1 if j < int(tag[:1]) * 2: @@ -372,300 +388,318 @@ def dc2fields(file): content = post_excerpt + post_content else: content = post_excerpt_xhtml + post_content_xhtml - content = content.replace('\\n', '') + content = content.replace("\\n", "") post_format = "html" - kind = 'article' # TODO: Recognise pages - status = 'published' # TODO: Find a way for draft posts + kind = "article" # TODO: Recognise pages + status = "published" # TODO: Find a way for draft posts - yield (post_title, content, slugify(post_title, regex_subs=subs), - post_creadt, author, categories, tags, status, kind, - post_format) + yield ( + post_title, + content, + slugify(post_title, regex_subs=subs), + post_creadt, + author, + categories, + tags, + status, + kind, + post_format, + ) -def posterous2fields(api_token, email, password): - """Imports posterous posts""" - import base64 - from datetime import timedelta +def _get_tumblr_posts(api_key, blogname, offset=0): import json import urllib.request as urllib_request - def get_posterous_posts(api_token, email, password, page=1): - base64string = base64.encodestring( - ("{}:{}".format(email, password)).encode('utf-8')).replace('\n', '') - url = ("http://posterous.com/api/v2/users/me/sites/primary/" - "posts?api_token=%s&page=%d") % (api_token, page) - request = urllib_request.Request(url) - request.add_header('Authorization', 'Basic %s' % base64string.decode()) - handle = urllib_request.urlopen(request) - posts = json.loads(handle.read().decode('utf-8')) - return posts - - page = 1 - posts = get_posterous_posts(api_token, email, password, page) - subs = DEFAULT_CONFIG['SLUG_REGEX_SUBSTITUTIONS'] - while len(posts) > 0: - posts = get_posterous_posts(api_token, email, password, page) - page += 1 - - for post in posts: - slug = post.get('slug') - if not slug: - slug = slugify(post.get('title'), regex_subs=subs) - tags = [tag.get('name') for tag in post.get('tags')] - raw_date = post.get('display_date') - date_object = SafeDatetime.strptime( - raw_date[:-6], '%Y/%m/%d %H:%M:%S') - offset = int(raw_date[-5:]) - delta = timedelta(hours=(offset / 100)) - date_object -= delta - date = date_object.strftime('%Y-%m-%d %H:%M') - kind = 'article' # TODO: Recognise pages - status = 'published' # TODO: Find a way for draft posts - - yield (post.get('title'), post.get('body_cleaned'), - slug, date, post.get('user').get('display_name'), - [], tags, status, kind, 'html') + url = ( + "https://api.tumblr.com/v2/blog/%s.tumblr.com/" + "posts?api_key=%s&offset=%d&filter=raw" + ) % (blogname, api_key, offset) + request = urllib_request.Request(url) + handle = urllib_request.urlopen(request) + posts = json.loads(handle.read().decode("utf-8")) + return posts.get("response").get("posts") def tumblr2fields(api_key, blogname): - """ Imports Tumblr posts (API v2)""" - import json - import urllib.request as urllib_request - - def get_tumblr_posts(api_key, blogname, offset=0): - url = ("https://api.tumblr.com/v2/blog/%s.tumblr.com/" - "posts?api_key=%s&offset=%d&filter=raw") % ( - blogname, api_key, offset) - request = urllib_request.Request(url) - handle = urllib_request.urlopen(request) - posts = json.loads(handle.read().decode('utf-8')) - return posts.get('response').get('posts') - + """Imports Tumblr posts (API v2)""" offset = 0 - posts = get_tumblr_posts(api_key, blogname, offset) - subs = DEFAULT_CONFIG['SLUG_REGEX_SUBSTITUTIONS'] + posts = _get_tumblr_posts(api_key, blogname, offset) + subs = DEFAULT_CONFIG["SLUG_REGEX_SUBSTITUTIONS"] while len(posts) > 0: for post in posts: - title = \ - post.get('title') or \ - post.get('source_title') or \ - post.get('type').capitalize() - slug = post.get('slug') or slugify(title, regex_subs=subs) - tags = post.get('tags') - timestamp = post.get('timestamp') - date = SafeDatetime.fromtimestamp(int(timestamp)).strftime( - "%Y-%m-%d %H:%M:%S") - slug = SafeDatetime.fromtimestamp(int(timestamp)).strftime( - "%Y-%m-%d-") + slug - format = post.get('format') - content = post.get('body') - type = post.get('type') - if type == 'photo': - if format == 'markdown': - fmtstr = '![%s](%s)' + title = ( + post.get("title") + or post.get("source_title") + or post.get("type").capitalize() + ) + slug = post.get("slug") or slugify(title, regex_subs=subs) + tags = post.get("tags") + timestamp = post.get("timestamp") + date = SafeDatetime.fromtimestamp( + int(timestamp), tz=datetime.timezone.utc + ).strftime("%Y-%m-%d %H:%M:%S%z") + slug = ( + SafeDatetime.fromtimestamp( + int(timestamp), tz=datetime.timezone.utc + ).strftime("%Y-%m-%d-") + + slug + ) + format = post.get("format") + content = post.get("body") + type = post.get("type") + if type == "photo": + if format == "markdown": + fmtstr = "![%s](%s)" else: fmtstr = '%s' - content = '' - for photo in post.get('photos'): - content += '\n'.join( - fmtstr % (photo.get('caption'), - photo.get('original_size').get('url'))) - content += '\n\n' + post.get('caption') - elif type == 'quote': - if format == 'markdown': - fmtstr = '\n\n— %s' + content = "\n".join( + fmtstr + % (photo.get("caption"), photo.get("original_size").get("url")) + for photo in post.get("photos") + ) + elif type == "quote": + if format == "markdown": + fmtstr = "\n\n— %s" else: - fmtstr = '

        — %s

        ' - content = post.get('text') + fmtstr % post.get('source') - elif type == 'link': - if format == 'markdown': - fmtstr = '[via](%s)\n\n' + fmtstr = "

        — %s

        " + content = post.get("text") + fmtstr % post.get("source") + elif type == "link": + if format == "markdown": + fmtstr = "[via](%s)\n\n" else: fmtstr = '

        via

        \n' - content = fmtstr % post.get('url') + post.get('description') - elif type == 'audio': - if format == 'markdown': - fmtstr = '[via](%s)\n\n' + content = fmtstr % post.get("url") + post.get("description") + elif type == "audio": + if format == "markdown": + fmtstr = "[via](%s)\n\n" else: fmtstr = '

        via

        \n' - content = fmtstr % post.get('source_url') + \ - post.get('caption') + \ - post.get('player') - elif type == 'video': - if format == 'markdown': - fmtstr = '[via](%s)\n\n' + content = ( + fmtstr % post.get("source_url") + + post.get("caption") + + post.get("player") + ) + elif type == "video": + if format == "markdown": + fmtstr = "[via](%s)\n\n" else: fmtstr = '

        via

        \n' - source = fmtstr % post.get('source_url') - caption = post.get('caption') - players = '\n'.join(player.get('embed_code') - for player in post.get('player')) + source = fmtstr % post.get("source_url") + caption = post.get("caption") + players = [ + # If embed_code is False, couldn't get the video + player.get("embed_code") or None + for player in post.get("player") + ] + # If there are no embeddable players, say so, once + if len(players) > 0 and all(player is None for player in players): + players = "

        (This video isn't available anymore.)

        \n" + else: + players = "\n".join(players) content = source + caption + players - elif type == 'answer': - title = post.get('question') - content = ('

        ' - '%s' - ': %s' - '

        \n' - ' %s' % (post.get('asking_name'), - post.get('asking_url'), - post.get('question'), - post.get('answer'))) + elif type == "answer": + title = post.get("question") + content = ( + "

        " + '%s' + ": %s" + "

        \n" + " %s" + % ( + post.get("asking_name"), + post.get("asking_url"), + post.get("question"), + post.get("answer"), + ) + ) - content = content.rstrip() + '\n' - kind = 'article' - status = 'published' # TODO: Find a way for draft posts + content = content.rstrip() + "\n" + kind = "article" + status = "published" # TODO: Find a way for draft posts - yield (title, content, slug, date, post.get('blog_name'), [type], - tags, status, kind, format) + yield ( + title, + content, + slug, + date, + post.get("blog_name"), + [type], + tags, + status, + kind, + format, + ) offset += len(posts) - posts = get_tumblr_posts(api_key, blogname, offset) + posts = _get_tumblr_posts(api_key, blogname, offset) def feed2fields(file): """Read a feed and yield pelican fields""" import feedparser + d = feedparser.parse(file) - subs = DEFAULT_CONFIG['SLUG_REGEX_SUBSTITUTIONS'] + subs = DEFAULT_CONFIG["SLUG_REGEX_SUBSTITUTIONS"] for entry in d.entries: - date = (time.strftime('%Y-%m-%d %H:%M', entry.updated_parsed) - if hasattr(entry, 'updated_parsed') else None) - author = entry.author if hasattr(entry, 'author') else None - tags = ([e['term'] for e in entry.tags] - if hasattr(entry, 'tags') else None) + date = ( + time.strftime("%Y-%m-%d %H:%M", entry.updated_parsed) + if hasattr(entry, "updated_parsed") + else None + ) + author = entry.author if hasattr(entry, "author") else None + tags = [e["term"] for e in entry.tags] if hasattr(entry, "tags") else None slug = slugify(entry.title, regex_subs=subs) - kind = 'article' - yield (entry.title, entry.description, slug, date, - author, [], tags, None, kind, 'html') + kind = "article" + yield ( + entry.title, + entry.description, + slug, + date, + author, + [], + tags, + None, + kind, + "html", + ) -def build_header(title, date, author, categories, tags, slug, - status=None, attachments=None): +def build_header( + title, date, author, categories, tags, slug, status=None, attachments=None +): """Build a header from a list of fields""" from docutils.utils import column_width - header = '{}\n{}\n'.format(title, '#' * column_width(title)) + header = "{}\n{}\n".format(title, "#" * column_width(title)) if date: - header += ':date: %s\n' % date + header += ":date: %s\n" % date if author: - header += ':author: %s\n' % author + header += ":author: %s\n" % author if categories: - header += ':category: %s\n' % ', '.join(categories) + header += ":category: %s\n" % ", ".join(categories) if tags: - header += ':tags: %s\n' % ', '.join(tags) + header += ":tags: %s\n" % ", ".join(tags) if slug: - header += ':slug: %s\n' % slug + header += ":slug: %s\n" % slug if status: - header += ':status: %s\n' % status + header += ":status: %s\n" % status if attachments: - header += ':attachments: %s\n' % ', '.join(attachments) - header += '\n' + header += ":attachments: %s\n" % ", ".join(attachments) + header += "\n" return header -def build_asciidoc_header(title, date, author, categories, tags, slug, - status=None, attachments=None): +def build_asciidoc_header( + title, date, author, categories, tags, slug, status=None, attachments=None +): """Build a header from a list of fields""" - header = '= %s\n' % title + header = "= %s\n" % title if author: - header += '%s\n' % author + header += "%s\n" % author if date: - header += '%s\n' % date + header += "%s\n" % date if categories: - header += ':category: %s\n' % ', '.join(categories) + header += ":category: %s\n" % ", ".join(categories) if tags: - header += ':tags: %s\n' % ', '.join(tags) + header += ":tags: %s\n" % ", ".join(tags) if slug: - header += ':slug: %s\n' % slug + header += ":slug: %s\n" % slug if status: - header += ':status: %s\n' % status + header += ":status: %s\n" % status if attachments: - header += ':attachments: %s\n' % ', '.join(attachments) - header += '\n' + header += ":attachments: %s\n" % ", ".join(attachments) + header += "\n" return header -def build_markdown_header(title, date, author, categories, tags, - slug, status=None, attachments=None): +def build_markdown_header( + title, date, author, categories, tags, slug, status=None, attachments=None +): """Build a header from a list of fields""" - header = 'Title: %s\n' % title + header = "Title: %s\n" % title if date: - header += 'Date: %s\n' % date + header += "Date: %s\n" % date if author: - header += 'Author: %s\n' % author + header += "Author: %s\n" % author if categories: - header += 'Category: %s\n' % ', '.join(categories) + header += "Category: %s\n" % ", ".join(categories) if tags: - header += 'Tags: %s\n' % ', '.join(tags) + header += "Tags: %s\n" % ", ".join(tags) if slug: - header += 'Slug: %s\n' % slug + header += "Slug: %s\n" % slug if status: - header += 'Status: %s\n' % status + header += "Status: %s\n" % status if attachments: - header += 'Attachments: %s\n' % ', '.join(attachments) - header += '\n' + header += "Attachments: %s\n" % ", ".join(attachments) + header += "\n" return header -def get_ext(out_markup, in_markup='html'): - if out_markup == 'asciidoc': - ext = '.adoc' - elif in_markup == 'markdown' or out_markup == 'markdown': - ext = '.md' +def get_ext(out_markup, in_markup="html"): + if out_markup == "asciidoc": + ext = ".adoc" + elif in_markup == "markdown" or out_markup == "markdown": + ext = ".md" else: - ext = '.rst' + ext = ".rst" return ext -def get_out_filename(output_path, filename, ext, kind, - dirpage, dircat, categories, wp_custpost, slug_subs): +def get_out_filename( + output_path, + filename, + ext, + kind, + dirpage, + dircat, + categories, + wp_custpost, + slug_subs, +): filename = os.path.basename(filename) # Enforce filename restrictions for various filesystems at once; see # https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words # we do not need to filter words because an extension will be appended - filename = re.sub(r'[<>:"/\\|?*^% ]', '-', filename) # invalid chars - filename = filename.lstrip('.') # should not start with a dot + filename = re.sub(r'[<>:"/\\|?*^% ]', "-", filename) # invalid chars + filename = filename.lstrip(".") # should not start with a dot if not filename: - filename = '_' + filename = "_" filename = filename[:249] # allow for 5 extra characters out_filename = os.path.join(output_path, filename + ext) # option to put page posts in pages/ subdirectory - if dirpage and kind == 'page': - pages_dir = os.path.join(output_path, 'pages') + if dirpage and kind == "page": + pages_dir = os.path.join(output_path, "pages") if not os.path.isdir(pages_dir): os.mkdir(pages_dir) out_filename = os.path.join(pages_dir, filename + ext) - elif not dirpage and kind == 'page': + elif not dirpage and kind == "page": pass # option to put wp custom post types in directories with post type # names. Custom post types can also have categories so option to # create subdirectories with category names - elif kind != 'article': + elif kind != "article": if wp_custpost: typename = slugify(kind, regex_subs=slug_subs) else: - typename = '' - kind = 'article' + typename = "" + kind = "article" if dircat and (len(categories) > 0): - catname = slugify( - categories[0], regex_subs=slug_subs, preserve_case=True) + catname = slugify(categories[0], regex_subs=slug_subs, preserve_case=True) else: - catname = '' - out_filename = os.path.join(output_path, typename, - catname, filename + ext) + catname = "" + out_filename = os.path.join(output_path, typename, catname, filename + ext) if not os.path.isdir(os.path.join(output_path, typename, catname)): os.makedirs(os.path.join(output_path, typename, catname)) # option to put files in directories with categories names elif dircat and (len(categories) > 0): - catname = slugify( - categories[0], regex_subs=slug_subs, preserve_case=True) + catname = slugify(categories[0], regex_subs=slug_subs, preserve_case=True) out_filename = os.path.join(output_path, catname, filename + ext) if not os.path.isdir(os.path.join(output_path, catname)): os.mkdir(os.path.join(output_path, catname)) @@ -678,18 +712,19 @@ def get_attachments(xml): of the attachment_urls """ soup = xml_to_soup(xml) - items = soup.rss.channel.findAll('item') + items = soup.rss.channel.findAll("item") names = {} attachments = [] for item in items: - kind = item.find('post_type').string - post_name = item.find('post_name').string - post_id = item.find('post_id').string + kind = item.find("post_type").string + post_name = item.find("post_name").string + post_id = item.find("post_id").string - if kind == 'attachment': - attachments.append((item.find('post_parent').string, - item.find('attachment_url').string)) + if kind == "attachment": + attachments.append( + (item.find("post_parent").string, item.find("attachment_url").string) + ) else: filename = get_filename(post_name, post_id) names[post_id] = filename @@ -714,23 +749,23 @@ def download_attachments(output_path, urls): path = urlparse(url).path # teardown path and rebuild to negate any errors with # os.path.join and leading /'s - path = path.split('/') + path = path.split("/") filename = path.pop(-1) - localpath = '' + localpath = "" for item in path: - if sys.platform != 'win32' or ':' not in item: + if sys.platform != "win32" or ":" not in item: localpath = os.path.join(localpath, item) full_path = os.path.join(output_path, localpath) # Generate percent-encoded URL scheme, netloc, path, query, fragment = urlsplit(url) - if scheme != 'file': + if scheme != "file": path = quote(path) url = urlunsplit((scheme, netloc, path, query, fragment)) if not os.path.exists(full_path): os.makedirs(full_path) - print('downloading {}'.format(filename)) + print(f"downloading {filename}") try: urlretrieve(url, os.path.join(full_path, filename)) locations[url] = os.path.join(localpath, filename) @@ -741,43 +776,61 @@ def download_attachments(output_path, urls): def is_pandoc_needed(in_markup): - return in_markup in ('html', 'wp-html') + return in_markup in ("html", "wp-html") def get_pandoc_version(): - cmd = ['pandoc', '--version'] + cmd = ["pandoc", "--version"] try: - output = subprocess.check_output(cmd, universal_newlines=True) + output = subprocess.check_output(cmd, text=True) except (subprocess.CalledProcessError, OSError) as e: logger.warning("Pandoc version unknown: %s", e) return () - return tuple(int(i) for i in output.split()[1].split('.')) + return tuple(int(i) for i in output.split()[1].split(".")) def update_links_to_attached_files(content, attachments): for old_url, new_path in attachments.items(): # url may occur both with http:// and https:// - http_url = old_url.replace('https://', 'http://') - https_url = old_url.replace('http://', 'https://') + http_url = old_url.replace("https://", "http://") + https_url = old_url.replace("http://", "https://") for url in [http_url, https_url]: - content = content.replace(url, '{static}' + new_path) + content = content.replace(url, "{static}" + new_path) return content def fields2pelican( - fields, out_markup, output_path, - dircat=False, strip_raw=False, disable_slugs=False, - dirpage=False, filename_template=None, filter_author=None, - wp_custpost=False, wp_attach=False, attachments=None): - + fields, + out_markup, + output_path, + dircat=False, + strip_raw=False, + disable_slugs=False, + dirpage=False, + filename_template=None, + filter_author=None, + wp_custpost=False, + wp_attach=False, + attachments=None, +): pandoc_version = get_pandoc_version() posts_require_pandoc = [] - slug_subs = DEFAULT_CONFIG['SLUG_REGEX_SUBSTITUTIONS'] + slug_subs = DEFAULT_CONFIG["SLUG_REGEX_SUBSTITUTIONS"] - for (title, content, filename, date, author, categories, tags, status, - kind, in_markup) in fields: + for ( + title, + content, + filename, + date, + author, + categories, + tags, + status, + kind, + in_markup, + ) in fields: if filter_author and filter_author != author: continue if is_pandoc_needed(in_markup) and not pandoc_version: @@ -795,88 +848,120 @@ def fields2pelican( links = None ext = get_ext(out_markup, in_markup) - if ext == '.adoc': - header = build_asciidoc_header(title, date, author, categories, - tags, slug, status, attachments) - elif ext == '.md': + if ext == ".adoc": + header = build_asciidoc_header( + title, date, author, categories, tags, slug, status, attachments + ) + elif ext == ".md": header = build_markdown_header( - title, date, author, categories, tags, slug, - status, links.values() if links else None) + title, + date, + author, + categories, + tags, + slug, + status, + links.values() if links else None, + ) else: - out_markup = 'rst' - header = build_header(title, date, author, categories, - tags, slug, status, links.values() - if links else None) + out_markup = "rst" + header = build_header( + title, + date, + author, + categories, + tags, + slug, + status, + links.values() if links else None, + ) out_filename = get_out_filename( - output_path, filename, ext, kind, dirpage, dircat, - categories, wp_custpost, slug_subs) + output_path, + filename, + ext, + kind, + dirpage, + dircat, + categories, + wp_custpost, + slug_subs, + ) print(out_filename) - if in_markup in ('html', 'wp-html'): - html_filename = os.path.join(output_path, filename + '.html') - - with open(html_filename, 'w', encoding='utf-8') as fp: + if in_markup in ("html", "wp-html"): + with tempfile.TemporaryDirectory() as tmpdir: + html_filename = os.path.join(tmpdir, "pandoc-input.html") # Replace newlines with paragraphs wrapped with

        so # HTML is valid before conversion - if in_markup == 'wp-html': + if in_markup == "wp-html": new_content = decode_wp_content(content) else: paragraphs = content.splitlines() - paragraphs = ['

        {}

        '.format(p) for p in paragraphs] - new_content = ''.join(paragraphs) + paragraphs = [f"

        {p}

        " for p in paragraphs] + new_content = "".join(paragraphs) + with open(html_filename, "w", encoding="utf-8") as fp: + fp.write(new_content) - fp.write(new_content) + if pandoc_version < (2,): + parse_raw = "--parse-raw" if not strip_raw else "" + wrap_none = ( + "--wrap=none" if pandoc_version >= (1, 16) else "--no-wrap" + ) + cmd = ( + "pandoc --normalize {0} --from=html" + ' --to={1} {2} -o "{3}" "{4}"' + ) + cmd = cmd.format( + parse_raw, + out_markup if out_markup != "markdown" else "gfm", + wrap_none, + out_filename, + html_filename, + ) + else: + from_arg = "-f html+raw_html" if not strip_raw else "-f html" + cmd = 'pandoc {0} --to={1}-smart --wrap=none -o "{2}" "{3}"' + cmd = cmd.format( + from_arg, + out_markup if out_markup != "markdown" else "gfm", + out_filename, + html_filename, + ) - if pandoc_version < (2,): - parse_raw = '--parse-raw' if not strip_raw else '' - wrap_none = '--wrap=none' \ - if pandoc_version >= (1, 16) else '--no-wrap' - cmd = ('pandoc --normalize {0} --from=html' - ' --to={1} {2} -o "{3}" "{4}"') - cmd = cmd.format(parse_raw, - out_markup if out_markup != 'markdown' else "gfm", - wrap_none, - out_filename, html_filename) - else: - from_arg = '-f html+raw_html' if not strip_raw else '-f html' - cmd = ('pandoc {0} --to={1}-smart --wrap=none -o "{2}" "{3}"') - cmd = cmd.format(from_arg, - out_markup if out_markup != 'markdown' else "gfm", - out_filename, html_filename) + try: + rc = subprocess.call(cmd, shell=True) + if rc < 0: + error = "Child was terminated by signal %d" % -rc + exit(error) - try: - rc = subprocess.call(cmd, shell=True) - if rc < 0: - error = 'Child was terminated by signal %d' % -rc + elif rc > 0: + error = "Please, check your Pandoc installation." + exit(error) + except OSError as e: + error = "Pandoc execution failed: %s" % e exit(error) - elif rc > 0: - error = 'Please, check your Pandoc installation.' - exit(error) - except OSError as e: - error = 'Pandoc execution failed: %s' % e - exit(error) - - os.remove(html_filename) - - with open(out_filename, encoding='utf-8') as fs: + with open(out_filename, encoding="utf-8") as fs: content = fs.read() - if out_markup == 'markdown': + if out_markup == "markdown": # In markdown, to insert a
        , end a line with two # or more spaces & then a end-of-line - content = content.replace('\\\n ', ' \n') - content = content.replace('\\\n', ' \n') + content = content.replace("\\\n ", " \n") + content = content.replace("\\\n", " \n") if wp_attach and links: content = update_links_to_attached_files(content, links) - with open(out_filename, 'w', encoding='utf-8') as fs: + with open(out_filename, "w", encoding="utf-8") as fs: fs.write(header + content) if posts_require_pandoc: - logger.error("Pandoc must be installed to import the following posts:" - "\n {}".format("\n ".join(posts_require_pandoc))) + logger.error( + "Pandoc must be installed to import the following posts:" "\n {}".format( + "\n ".join(posts_require_pandoc) + ) + ) if wp_attach and attachments and None in attachments: print("downloading attachments that don't have a parent post") @@ -886,125 +971,137 @@ def fields2pelican( def main(): parser = argparse.ArgumentParser( - description="Transform feed, Blogger, Dotclear, Posterous, Tumblr, or " - "WordPress files into reST (rst) or Markdown (md) files. " - "Be sure to have pandoc installed.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter) + description="Transform feed, Blogger, Dotclear, Tumblr, or " + "WordPress files into reST (rst) or Markdown (md) files. " + "Be sure to have pandoc installed.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument(dest="input", help="The input file to read") parser.add_argument( - dest='input', help='The input file to read') + "--blogger", action="store_true", dest="blogger", help="Blogger XML export" + ) parser.add_argument( - '--blogger', action='store_true', dest='blogger', - help='Blogger XML export') + "--dotclear", action="store_true", dest="dotclear", help="Dotclear export" + ) parser.add_argument( - '--dotclear', action='store_true', dest='dotclear', - help='Dotclear export') + "--tumblr", action="store_true", dest="tumblr", help="Tumblr export" + ) parser.add_argument( - '--posterous', action='store_true', dest='posterous', - help='Posterous export') + "--wpfile", action="store_true", dest="wpfile", help="Wordpress XML export" + ) parser.add_argument( - '--tumblr', action='store_true', dest='tumblr', - help='Tumblr export') + "--feed", action="store_true", dest="feed", help="Feed to parse" + ) parser.add_argument( - '--wpfile', action='store_true', dest='wpfile', - help='Wordpress XML export') + "-o", "--output", dest="output", default="content", help="Output path" + ) parser.add_argument( - '--feed', action='store_true', dest='feed', - help='Feed to parse') + "-m", + "--markup", + dest="markup", + default="rst", + help="Output markup format (supports rst & markdown)", + ) parser.add_argument( - '-o', '--output', dest='output', default='content', - help='Output path') + "--dir-cat", + action="store_true", + dest="dircat", + help="Put files in directories with categories name", + ) parser.add_argument( - '-m', '--markup', dest='markup', default='rst', - help='Output markup format (supports rst & markdown)') + "--dir-page", + action="store_true", + dest="dirpage", + help=( + 'Put files recognised as pages in "pages/" sub-directory' + " (blogger and wordpress import only)" + ), + ) parser.add_argument( - '--dir-cat', action='store_true', dest='dircat', - help='Put files in directories with categories name') + "--filter-author", + dest="author", + help="Import only post from the specified author", + ) parser.add_argument( - '--dir-page', action='store_true', dest='dirpage', - help=('Put files recognised as pages in "pages/" sub-directory' - ' (blogger and wordpress import only)')) - parser.add_argument( - '--filter-author', dest='author', - help='Import only post from the specified author') - parser.add_argument( - '--strip-raw', action='store_true', dest='strip_raw', + "--strip-raw", + action="store_true", + dest="strip_raw", help="Strip raw HTML code that can't be converted to " - "markup such as flash embeds or iframes (wordpress import only)") + "markup such as flash embeds or iframes (wordpress import only)", + ) parser.add_argument( - '--wp-custpost', action='store_true', - dest='wp_custpost', - help='Put wordpress custom post types in directories. If used with ' - '--dir-cat option directories will be created as ' - '/post_type/category/ (wordpress import only)') + "--wp-custpost", + action="store_true", + dest="wp_custpost", + help="Put wordpress custom post types in directories. If used with " + "--dir-cat option directories will be created as " + "/post_type/category/ (wordpress import only)", + ) parser.add_argument( - '--wp-attach', action='store_true', dest='wp_attach', - help='(wordpress import only) Download files uploaded to wordpress as ' - 'attachments. Files will be added to posts as a list in the post ' - 'header. All files will be downloaded, even if ' - "they aren't associated with a post. Files will be downloaded " - 'with their original path inside the output directory. ' - 'e.g. output/wp-uploads/date/postname/file.jpg ' - '-- Requires an internet connection --') + "--wp-attach", + action="store_true", + dest="wp_attach", + help="(wordpress import only) Download files uploaded to wordpress as " + "attachments. Files will be added to posts as a list in the post " + "header. All files will be downloaded, even if " + "they aren't associated with a post. Files will be downloaded " + "with their original path inside the output directory. " + "e.g. output/wp-uploads/date/postname/file.jpg " + "-- Requires an internet connection --", + ) parser.add_argument( - '--disable-slugs', action='store_true', - dest='disable_slugs', - help='Disable storing slugs from imported posts within output. ' - 'With this disabled, your Pelican URLs may not be consistent ' - 'with your original posts.') + "--disable-slugs", + action="store_true", + dest="disable_slugs", + help="Disable storing slugs from imported posts within output. " + "With this disabled, your Pelican URLs may not be consistent " + "with your original posts.", + ) parser.add_argument( - '-e', '--email', dest='email', - help="Email address (posterous import only)") - parser.add_argument( - '-p', '--password', dest='password', - help="Password (posterous import only)") - parser.add_argument( - '-b', '--blogname', dest='blogname', - help="Blog name (Tumblr import only)") + "-b", "--blogname", dest="blogname", help="Blog name (Tumblr import only)" + ) args = parser.parse_args() input_type = None if args.blogger: - input_type = 'blogger' + input_type = "blogger" elif args.dotclear: - input_type = 'dotclear' - elif args.posterous: - input_type = 'posterous' + input_type = "dotclear" elif args.tumblr: - input_type = 'tumblr' + input_type = "tumblr" elif args.wpfile: - input_type = 'wordpress' + input_type = "wordpress" elif args.feed: - input_type = 'feed' + input_type = "feed" else: - error = ('You must provide either --blogger, --dotclear, ' - '--posterous, --tumblr, --wpfile or --feed options') + error = ( + "You must provide either --blogger, --dotclear, " + "--tumblr, --wpfile or --feed options" + ) exit(error) if not os.path.exists(args.output): try: os.mkdir(args.output) except OSError: - error = 'Unable to create the output folder: ' + args.output + error = "Unable to create the output folder: " + args.output exit(error) - if args.wp_attach and input_type != 'wordpress': - error = ('You must be importing a wordpress xml ' - 'to use the --wp-attach option') + if args.wp_attach and input_type != "wordpress": + error = "You must be importing a wordpress xml " "to use the --wp-attach option" exit(error) - if input_type == 'blogger': + if input_type == "blogger": fields = blogger2fields(args.input) - elif input_type == 'dotclear': + elif input_type == "dotclear": fields = dc2fields(args.input) - elif input_type == 'posterous': - fields = posterous2fields(args.input, args.email, args.password) - elif input_type == 'tumblr': + elif input_type == "tumblr": fields = tumblr2fields(args.input, args.blogname) - elif input_type == 'wordpress': + elif input_type == "wordpress": fields = wp2fields(args.input, args.wp_custpost or False) - elif input_type == 'feed': + elif input_type == "feed": fields = feed2fields(args.input) if args.wp_attach: @@ -1014,12 +1111,16 @@ def main(): # init logging init() - fields2pelican(fields, args.markup, args.output, - dircat=args.dircat or False, - dirpage=args.dirpage or False, - strip_raw=args.strip_raw or False, - disable_slugs=args.disable_slugs or False, - filter_author=args.author, - wp_custpost=args.wp_custpost or False, - wp_attach=args.wp_attach or False, - attachments=attachments or None) + fields2pelican( + fields, + args.markup, + args.output, + dircat=args.dircat or False, + dirpage=args.dirpage or False, + strip_raw=args.strip_raw or False, + disable_slugs=args.disable_slugs or False, + filter_author=args.author, + wp_custpost=args.wp_custpost or False, + wp_attach=args.wp_attach or False, + attachments=attachments or None, + ) diff --git a/pelican/tools/pelican_quickstart.py b/pelican/tools/pelican_quickstart.py index 4b6d93cc..db00ce70 100755 --- a/pelican/tools/pelican_quickstart.py +++ b/pelican/tools/pelican_quickstart.py @@ -19,6 +19,7 @@ except ImportError: try: import tzlocal + if hasattr(tzlocal.get_localzone(), "zone"): _DEFAULT_TIMEZONE = tzlocal.get_localzone().zone else: @@ -28,55 +29,51 @@ except ModuleNotFoundError: from pelican import __version__ -locale.setlocale(locale.LC_ALL, '') +locale.setlocale(locale.LC_ALL, "") try: _DEFAULT_LANGUAGE = locale.getlocale()[0] except ValueError: # Don't fail on macosx: "unknown locale: UTF-8" _DEFAULT_LANGUAGE = None if _DEFAULT_LANGUAGE is None: - _DEFAULT_LANGUAGE = 'en' + _DEFAULT_LANGUAGE = "en" else: - _DEFAULT_LANGUAGE = _DEFAULT_LANGUAGE.split('_')[0] + _DEFAULT_LANGUAGE = _DEFAULT_LANGUAGE.split("_")[0] -_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), - "templates") +_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") _jinja_env = Environment( loader=FileSystemLoader(_TEMPLATES_DIR), trim_blocks=True, ) -_GITHUB_PAGES_BRANCHES = { - 'personal': 'main', - 'project': 'gh-pages' -} +_GITHUB_PAGES_BRANCHES = {"personal": "main", "project": "gh-pages"} CONF = { - 'pelican': 'pelican', - 'pelicanopts': '', - 'basedir': os.curdir, - 'ftp_host': 'localhost', - 'ftp_user': 'anonymous', - 'ftp_target_dir': '/', - 'ssh_host': 'localhost', - 'ssh_port': 22, - 'ssh_user': 'root', - 'ssh_target_dir': '/var/www', - 's3_bucket': 'my_s3_bucket', - 'cloudfiles_username': 'my_rackspace_username', - 'cloudfiles_api_key': 'my_rackspace_api_key', - 'cloudfiles_container': 'my_cloudfiles_container', - 'dropbox_dir': '~/Dropbox/Public/', - 'github_pages_branch': _GITHUB_PAGES_BRANCHES['project'], - 'default_pagination': 10, - 'siteurl': '', - 'lang': _DEFAULT_LANGUAGE, - 'timezone': _DEFAULT_TIMEZONE + "pelican": "pelican", + "pelicanopts": "", + "basedir": os.curdir, + "ftp_host": "localhost", + "ftp_user": "anonymous", + "ftp_target_dir": "/", + "ssh_host": "localhost", + "ssh_port": 22, + "ssh_user": "root", + "ssh_target_dir": "/var/www", + "s3_bucket": "my_s3_bucket", + "cloudfiles_username": "my_rackspace_username", + "cloudfiles_api_key": "my_rackspace_api_key", + "cloudfiles_container": "my_cloudfiles_container", + "dropbox_dir": "~/Dropbox/Public/", + "github_pages_branch": _GITHUB_PAGES_BRANCHES["project"], + "default_pagination": 10, + "siteurl": "", + "lang": _DEFAULT_LANGUAGE, + "timezone": _DEFAULT_TIMEZONE, } # url for list of valid timezones -_TZ_URL = 'https://en.wikipedia.org/wiki/List_of_tz_database_time_zones' +_TZ_URL = "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" # Create a 'marked' default path, to determine if someone has supplied @@ -90,12 +87,12 @@ _DEFAULT_PATH = _DEFAULT_PATH_TYPE(os.curdir) def ask(question, answer=str, default=None, length=None): if answer == str: - r = '' + r = "" while True: if default: - r = input('> {} [{}] '.format(question, default)) + r = input(f"> {question} [{default}] ") else: - r = input('> {} '.format(question)) + r = input(f"> {question} ") r = r.strip() @@ -104,10 +101,10 @@ def ask(question, answer=str, default=None, length=None): r = default break else: - print('You must enter something') + print("You must enter something") else: if length and len(r) != length: - print('Entry must be {} characters long'.format(length)) + print(f"Entry must be {length} characters long") else: break @@ -117,18 +114,18 @@ def ask(question, answer=str, default=None, length=None): r = None while True: if default is True: - r = input('> {} (Y/n) '.format(question)) + r = input(f"> {question} (Y/n) ") elif default is False: - r = input('> {} (y/N) '.format(question)) + r = input(f"> {question} (y/N) ") else: - r = input('> {} (y/n) '.format(question)) + r = input(f"> {question} (y/n) ") r = r.strip().lower() - if r in ('y', 'yes'): + if r in ("y", "yes"): r = True break - elif r in ('n', 'no'): + elif r in ("n", "no"): r = False break elif not r: @@ -141,9 +138,9 @@ def ask(question, answer=str, default=None, length=None): r = None while True: if default: - r = input('> {} [{}] '.format(question, default)) + r = input(f"> {question} [{default}] ") else: - r = input('> {} '.format(question)) + r = input(f"> {question} ") r = r.strip() @@ -155,11 +152,10 @@ def ask(question, answer=str, default=None, length=None): r = int(r) break except ValueError: - print('You must enter an integer') + print("You must enter an integer") return r else: - raise NotImplementedError( - 'Argument `answer` must be str, bool, or integer') + raise NotImplementedError("Argument `answer` must be str, bool, or integer") def ask_timezone(question, default, tzurl): @@ -178,162 +174,227 @@ def ask_timezone(question, default, tzurl): def render_jinja_template(tmpl_name: str, tmpl_vars: Mapping, target_path: str): try: - with open(os.path.join(CONF['basedir'], target_path), - 'w', encoding='utf-8') as fd: + with open( + os.path.join(CONF["basedir"], target_path), "w", encoding="utf-8" + ) as fd: _template = _jinja_env.get_template(tmpl_name) fd.write(_template.render(**tmpl_vars)) except OSError as e: - print('Error: {}'.format(e)) + print(f"Error: {e}") def main(): parser = argparse.ArgumentParser( description="A kickstarter for Pelican", - formatter_class=argparse.ArgumentDefaultsHelpFormatter) - parser.add_argument('-p', '--path', default=_DEFAULT_PATH, - help="The path to generate the blog into") - parser.add_argument('-t', '--title', metavar="title", - help='Set the title of the website') - parser.add_argument('-a', '--author', metavar="author", - help='Set the author name of the website') - parser.add_argument('-l', '--lang', metavar="lang", - help='Set the default web site language') + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "-p", "--path", default=_DEFAULT_PATH, help="The path to generate the blog into" + ) + parser.add_argument( + "-t", "--title", metavar="title", help="Set the title of the website" + ) + parser.add_argument( + "-a", "--author", metavar="author", help="Set the author name of the website" + ) + parser.add_argument( + "-l", "--lang", metavar="lang", help="Set the default web site language" + ) args = parser.parse_args() - print('''Welcome to pelican-quickstart v{v}. + print( + """Welcome to pelican-quickstart v{v}. This script will help you create a new Pelican-based website. Please answer the following questions so this script can generate the files needed by Pelican. - '''.format(v=__version__)) + """.format(v=__version__) + ) - project = os.path.join( - os.environ.get('VIRTUAL_ENV', os.curdir), '.project') - no_path_was_specified = hasattr(args.path, 'is_default_path') + project = os.path.join(os.environ.get("VIRTUAL_ENV", os.curdir), ".project") + no_path_was_specified = hasattr(args.path, "is_default_path") if os.path.isfile(project) and no_path_was_specified: - CONF['basedir'] = open(project).read().rstrip("\n") - print('Using project associated with current virtual environment. ' - 'Will save to:\n%s\n' % CONF['basedir']) + CONF["basedir"] = open(project).read().rstrip("\n") + print( + "Using project associated with current virtual environment. " + "Will save to:\n%s\n" % CONF["basedir"] + ) else: - CONF['basedir'] = os.path.abspath(os.path.expanduser( - ask('Where do you want to create your new web site?', - answer=str, default=args.path))) + CONF["basedir"] = os.path.abspath( + os.path.expanduser( + ask( + "Where do you want to create your new web site?", + answer=str, + default=args.path, + ) + ) + ) - CONF['sitename'] = ask('What will be the title of this web site?', - answer=str, default=args.title) - CONF['author'] = ask('Who will be the author of this web site?', - answer=str, default=args.author) - CONF['lang'] = ask('What will be the default language of this web site?', - str, args.lang or CONF['lang'], 2) + CONF["sitename"] = ask( + "What will be the title of this web site?", answer=str, default=args.title + ) + CONF["author"] = ask( + "Who will be the author of this web site?", answer=str, default=args.author + ) + CONF["lang"] = ask( + "What will be the default language of this web site?", + str, + args.lang or CONF["lang"], + 2, + ) - if ask('Do you want to specify a URL prefix? e.g., https://example.com ', - answer=bool, default=True): - CONF['siteurl'] = ask('What is your URL prefix? (see ' - 'above example; no trailing slash)', - str, CONF['siteurl']) + if ask( + "Do you want to specify a URL prefix? e.g., https://example.com ", + answer=bool, + default=True, + ): + CONF["siteurl"] = ask( + "What is your URL prefix? (see " "above example; no trailing slash)", + str, + CONF["siteurl"], + ) - CONF['with_pagination'] = ask('Do you want to enable article pagination?', - bool, bool(CONF['default_pagination'])) + CONF["with_pagination"] = ask( + "Do you want to enable article pagination?", + bool, + bool(CONF["default_pagination"]), + ) - if CONF['with_pagination']: - CONF['default_pagination'] = ask('How many articles per page ' - 'do you want?', - int, CONF['default_pagination']) + if CONF["with_pagination"]: + CONF["default_pagination"] = ask( + "How many articles per page " "do you want?", + int, + CONF["default_pagination"], + ) else: - CONF['default_pagination'] = False + CONF["default_pagination"] = False - CONF['timezone'] = ask_timezone('What is your time zone?', - CONF['timezone'], _TZ_URL) + CONF["timezone"] = ask_timezone( + "What is your time zone?", CONF["timezone"], _TZ_URL + ) - automation = ask('Do you want to generate a tasks.py/Makefile ' - 'to automate generation and publishing?', bool, True) + automation = ask( + "Do you want to generate a tasks.py/Makefile " + "to automate generation and publishing?", + bool, + True, + ) if automation: - if ask('Do you want to upload your website using FTP?', - answer=bool, default=False): - CONF['ftp'] = True, - CONF['ftp_host'] = ask('What is the hostname of your FTP server?', - str, CONF['ftp_host']) - CONF['ftp_user'] = ask('What is your username on that server?', - str, CONF['ftp_user']) - CONF['ftp_target_dir'] = ask('Where do you want to put your ' - 'web site on that server?', - str, CONF['ftp_target_dir']) - if ask('Do you want to upload your website using SSH?', - answer=bool, default=False): - CONF['ssh'] = True, - CONF['ssh_host'] = ask('What is the hostname of your SSH server?', - str, CONF['ssh_host']) - CONF['ssh_port'] = ask('What is the port of your SSH server?', - int, CONF['ssh_port']) - CONF['ssh_user'] = ask('What is your username on that server?', - str, CONF['ssh_user']) - CONF['ssh_target_dir'] = ask('Where do you want to put your ' - 'web site on that server?', - str, CONF['ssh_target_dir']) + if ask( + "Do you want to upload your website using FTP?", answer=bool, default=False + ): + CONF["ftp"] = (True,) + CONF["ftp_host"] = ask( + "What is the hostname of your FTP server?", str, CONF["ftp_host"] + ) + CONF["ftp_user"] = ask( + "What is your username on that server?", str, CONF["ftp_user"] + ) + CONF["ftp_target_dir"] = ask( + "Where do you want to put your " "web site on that server?", + str, + CONF["ftp_target_dir"], + ) + if ask( + "Do you want to upload your website using SSH?", answer=bool, default=False + ): + CONF["ssh"] = (True,) + CONF["ssh_host"] = ask( + "What is the hostname of your SSH server?", str, CONF["ssh_host"] + ) + CONF["ssh_port"] = ask( + "What is the port of your SSH server?", int, CONF["ssh_port"] + ) + CONF["ssh_user"] = ask( + "What is your username on that server?", str, CONF["ssh_user"] + ) + CONF["ssh_target_dir"] = ask( + "Where do you want to put your " "web site on that server?", + str, + CONF["ssh_target_dir"], + ) - if ask('Do you want to upload your website using Dropbox?', - answer=bool, default=False): - CONF['dropbox'] = True, - CONF['dropbox_dir'] = ask('Where is your Dropbox directory?', - str, CONF['dropbox_dir']) + if ask( + "Do you want to upload your website using Dropbox?", + answer=bool, + default=False, + ): + CONF["dropbox"] = (True,) + CONF["dropbox_dir"] = ask( + "Where is your Dropbox directory?", str, CONF["dropbox_dir"] + ) - if ask('Do you want to upload your website using S3?', - answer=bool, default=False): - CONF['s3'] = True, - CONF['s3_bucket'] = ask('What is the name of your S3 bucket?', - str, CONF['s3_bucket']) + if ask( + "Do you want to upload your website using S3?", answer=bool, default=False + ): + CONF["s3"] = (True,) + CONF["s3_bucket"] = ask( + "What is the name of your S3 bucket?", str, CONF["s3_bucket"] + ) - if ask('Do you want to upload your website using ' - 'Rackspace Cloud Files?', answer=bool, default=False): - CONF['cloudfiles'] = True, - CONF['cloudfiles_username'] = ask('What is your Rackspace ' - 'Cloud username?', str, - CONF['cloudfiles_username']) - CONF['cloudfiles_api_key'] = ask('What is your Rackspace ' - 'Cloud API key?', str, - CONF['cloudfiles_api_key']) - CONF['cloudfiles_container'] = ask('What is the name of your ' - 'Cloud Files container?', - str, - CONF['cloudfiles_container']) + if ask( + "Do you want to upload your website using " "Rackspace Cloud Files?", + answer=bool, + default=False, + ): + CONF["cloudfiles"] = (True,) + CONF["cloudfiles_username"] = ask( + "What is your Rackspace " "Cloud username?", + str, + CONF["cloudfiles_username"], + ) + CONF["cloudfiles_api_key"] = ask( + "What is your Rackspace " "Cloud API key?", + str, + CONF["cloudfiles_api_key"], + ) + CONF["cloudfiles_container"] = ask( + "What is the name of your " "Cloud Files container?", + str, + CONF["cloudfiles_container"], + ) - if ask('Do you want to upload your website using GitHub Pages?', - answer=bool, default=False): - CONF['github'] = True, - if ask('Is this your personal page (username.github.io)?', - answer=bool, default=False): - CONF['github_pages_branch'] = \ - _GITHUB_PAGES_BRANCHES['personal'] + if ask( + "Do you want to upload your website using GitHub Pages?", + answer=bool, + default=False, + ): + CONF["github"] = (True,) + if ask( + "Is this your personal page (username.github.io)?", + answer=bool, + default=False, + ): + CONF["github_pages_branch"] = _GITHUB_PAGES_BRANCHES["personal"] else: - CONF['github_pages_branch'] = \ - _GITHUB_PAGES_BRANCHES['project'] + CONF["github_pages_branch"] = _GITHUB_PAGES_BRANCHES["project"] try: - os.makedirs(os.path.join(CONF['basedir'], 'content')) + os.makedirs(os.path.join(CONF["basedir"], "content")) except OSError as e: - print('Error: {}'.format(e)) + print(f"Error: {e}") try: - os.makedirs(os.path.join(CONF['basedir'], 'output')) + os.makedirs(os.path.join(CONF["basedir"], "output")) except OSError as e: - print('Error: {}'.format(e)) + print(f"Error: {e}") conf_python = dict() for key, value in CONF.items(): conf_python[key] = repr(value) - render_jinja_template('pelicanconf.py.jinja2', conf_python, 'pelicanconf.py') + render_jinja_template("pelicanconf.py.jinja2", conf_python, "pelicanconf.py") - render_jinja_template('publishconf.py.jinja2', CONF, 'publishconf.py') + render_jinja_template("publishconf.py.jinja2", CONF, "publishconf.py") if automation: - render_jinja_template('tasks.py.jinja2', CONF, 'tasks.py') - render_jinja_template('Makefile.jinja2', CONF, 'Makefile') + render_jinja_template("tasks.py.jinja2", CONF, "tasks.py") + render_jinja_template("Makefile.jinja2", CONF, "Makefile") - print('Done. Your new project is available at %s' % CONF['basedir']) + print("Done. Your new project is available at %s" % CONF["basedir"]) if __name__ == "__main__": diff --git a/pelican/tools/pelican_themes.py b/pelican/tools/pelican_themes.py index 96d07c1f..c5b49b9f 100755 --- a/pelican/tools/pelican_themes.py +++ b/pelican/tools/pelican_themes.py @@ -8,70 +8,104 @@ import sys def err(msg, die=None): """Print an error message and exits if an exit code is given""" - sys.stderr.write(msg + '\n') + sys.stderr.write(msg + "\n") if die: - sys.exit(die if type(die) is int else 1) + sys.exit(die if isinstance(die, int) else 1) try: import pelican except ImportError: - err('Cannot import pelican.\nYou must ' - 'install Pelican in order to run this script.', - -1) + err( + "Cannot import pelican.\nYou must " + "install Pelican in order to run this script.", + -1, + ) global _THEMES_PATH _THEMES_PATH = os.path.join( - os.path.dirname( - os.path.abspath(pelican.__file__) - ), - 'themes' + os.path.dirname(os.path.abspath(pelican.__file__)), "themes" ) -__version__ = '0.2' -_BUILTIN_THEMES = ['simple', 'notmyidea'] +__version__ = "0.2" +_BUILTIN_THEMES = ["simple", "notmyidea"] def main(): """Main function""" - parser = argparse.ArgumentParser( - description="""Install themes for Pelican""") + parser = argparse.ArgumentParser(description="""Install themes for Pelican""") excl = parser.add_mutually_exclusive_group() excl.add_argument( - '-l', '--list', dest='action', action="store_const", const='list', - help="Show the themes already installed and exit") + "-l", + "--list", + dest="action", + action="store_const", + const="list", + help="Show the themes already installed and exit", + ) excl.add_argument( - '-p', '--path', dest='action', action="store_const", const='path', - help="Show the themes path and exit") + "-p", + "--path", + dest="action", + action="store_const", + const="path", + help="Show the themes path and exit", + ) excl.add_argument( - '-V', '--version', action='version', - version='pelican-themes v{}'.format(__version__), - help='Print the version of this script') + "-V", + "--version", + action="version", + version=f"pelican-themes v{__version__}", + help="Print the version of this script", + ) parser.add_argument( - '-i', '--install', dest='to_install', nargs='+', metavar="theme path", - help='The themes to install') + "-i", + "--install", + dest="to_install", + nargs="+", + metavar="theme path", + help="The themes to install", + ) parser.add_argument( - '-r', '--remove', dest='to_remove', nargs='+', metavar="theme name", - help='The themes to remove') + "-r", + "--remove", + dest="to_remove", + nargs="+", + metavar="theme name", + help="The themes to remove", + ) parser.add_argument( - '-U', '--upgrade', dest='to_upgrade', nargs='+', - metavar="theme path", help='The themes to upgrade') + "-U", + "--upgrade", + dest="to_upgrade", + nargs="+", + metavar="theme path", + help="The themes to upgrade", + ) parser.add_argument( - '-s', '--symlink', dest='to_symlink', nargs='+', metavar="theme path", + "-s", + "--symlink", + dest="to_symlink", + nargs="+", + metavar="theme path", help="Same as `--install', but create a symbolic link instead of " - "copying the theme. Useful for theme development") + "copying the theme. Useful for theme development", + ) parser.add_argument( - '-c', '--clean', dest='clean', action="store_true", - help="Remove the broken symbolic links of the theme path") + "-c", + "--clean", + dest="clean", + action="store_true", + help="Remove the broken symbolic links of the theme path", + ) parser.add_argument( - '-v', '--verbose', dest='verbose', - action="store_true", - help="Verbose output") + "-v", "--verbose", dest="verbose", action="store_true", help="Verbose output" + ) args = parser.parse_args() @@ -79,46 +113,46 @@ def main(): to_sym = args.to_symlink or args.clean if args.action: - if args.action == 'list': + if args.action == "list": list_themes(args.verbose) - elif args.action == 'path': + elif args.action == "path": print(_THEMES_PATH) elif to_install or args.to_remove or to_sym: if args.to_remove: if args.verbose: - print('Removing themes...') + print("Removing themes...") for i in args.to_remove: remove(i, v=args.verbose) if args.to_install: if args.verbose: - print('Installing themes...') + print("Installing themes...") for i in args.to_install: install(i, v=args.verbose) if args.to_upgrade: if args.verbose: - print('Upgrading themes...') + print("Upgrading themes...") for i in args.to_upgrade: install(i, v=args.verbose, u=True) if args.to_symlink: if args.verbose: - print('Linking themes...') + print("Linking themes...") for i in args.to_symlink: symlink(i, v=args.verbose) if args.clean: if args.verbose: - print('Cleaning the themes directory...') + print("Cleaning the themes directory...") clean(v=args.verbose) else: - print('No argument given... exiting.') + print("No argument given... exiting.") def themes(): @@ -135,66 +169,67 @@ def themes(): def list_themes(v=False): """Display the list of the themes""" - for t, l in themes(): + for theme_path, link_target in themes(): if not v: - t = os.path.basename(t) - if l: + theme_path = os.path.basename(theme_path) + if link_target: if v: - print(t + (" (symbolic link to `" + l + "')")) + print(theme_path + (" (symbolic link to `" + link_target + "')")) else: - print(t + '@') + print(theme_path + "@") else: - print(t) + print(theme_path) def remove(theme_name, v=False): """Removes a theme""" - theme_name = theme_name.replace('/', '') + theme_name = theme_name.replace("/", "") target = os.path.join(_THEMES_PATH, theme_name) if theme_name in _BUILTIN_THEMES: - err(theme_name + ' is a builtin theme.\n' - 'You cannot remove a builtin theme with this script, ' - 'remove it by hand if you want.') + err( + theme_name + " is a builtin theme.\n" + "You cannot remove a builtin theme with this script, " + "remove it by hand if you want." + ) elif os.path.islink(target): if v: - print('Removing link `' + target + "'") + print("Removing link `" + target + "'") os.remove(target) elif os.path.isdir(target): if v: - print('Removing directory `' + target + "'") + print("Removing directory `" + target + "'") shutil.rmtree(target) elif os.path.exists(target): - err(target + ' : not a valid theme') + err(target + " : not a valid theme") else: - err(target + ' : no such file or directory') + err(target + " : no such file or directory") def install(path, v=False, u=False): """Installs a theme""" if not os.path.exists(path): - err(path + ' : no such file or directory') + err(path + " : no such file or directory") elif not os.path.isdir(path): - err(path + ' : not a directory') + err(path + " : not a directory") else: theme_name = os.path.basename(os.path.normpath(path)) theme_path = os.path.join(_THEMES_PATH, theme_name) exists = os.path.exists(theme_path) if exists and not u: - err(path + ' : already exists') - elif exists and u: + err(path + " : already exists") + elif exists: remove(theme_name, v) install(path, v) else: if v: - print("Copying '{p}' to '{t}' ...".format(p=path, - t=theme_path)) + print(f"Copying '{path}' to '{theme_path}' ...") try: shutil.copytree(path, theme_path) try: - if os.name == 'posix': + if os.name == "posix": for root, dirs, files in os.walk(theme_path): for d in dirs: dname = os.path.join(root, d) @@ -203,35 +238,41 @@ def install(path, v=False, u=False): fname = os.path.join(root, f) os.chmod(fname, 420) # 0o644 except OSError as e: - err("Cannot change permissions of files " - "or directory in `{r}':\n{e}".format(r=theme_path, - e=str(e)), - die=False) + err( + "Cannot change permissions of files " + "or directory in `{r}':\n{e}".format(r=theme_path, e=str(e)), + die=False, + ) except Exception as e: - err("Cannot copy `{p}' to `{t}':\n{e}".format( - p=path, t=theme_path, e=str(e))) + err( + "Cannot copy `{p}' to `{t}':\n{e}".format( + p=path, t=theme_path, e=str(e) + ) + ) def symlink(path, v=False): """Symbolically link a theme""" if not os.path.exists(path): - err(path + ' : no such file or directory') + err(path + " : no such file or directory") elif not os.path.isdir(path): - err(path + ' : not a directory') + err(path + " : not a directory") else: theme_name = os.path.basename(os.path.normpath(path)) theme_path = os.path.join(_THEMES_PATH, theme_name) if os.path.exists(theme_path): - err(path + ' : already exists') + err(path + " : already exists") else: if v: - print("Linking `{p}' to `{t}' ...".format( - p=path, t=theme_path)) + print(f"Linking `{path}' to `{theme_path}' ...") try: os.symlink(path, theme_path) except Exception as e: - err("Cannot link `{p}' to `{t}':\n{e}".format( - p=path, t=theme_path, e=str(e))) + err( + "Cannot link `{p}' to `{t}':\n{e}".format( + p=path, t=theme_path, e=str(e) + ) + ) def is_broken_link(path): @@ -245,15 +286,14 @@ def clean(v=False): c = 0 for path in os.listdir(_THEMES_PATH): path = os.path.join(_THEMES_PATH, path) - if os.path.islink(path): - if is_broken_link(path): - if v: - print('Removing {}'.format(path)) - try: - os.remove(path) - except OSError: - print('Error: cannot remove {}'.format(path)) - else: - c += 1 + if os.path.islink(path) and is_broken_link(path): + if v: + print(f"Removing {path}") + try: + os.remove(path) + except OSError: + print(f"Error: cannot remove {path}") + else: + c += 1 - print("\nRemoved {} broken links".format(c)) + print(f"\nRemoved {c} broken links") diff --git a/pelican/tools/templates/pelicanconf.py.jinja2 b/pelican/tools/templates/pelicanconf.py.jinja2 index 1112ac88..d2e92d4b 100644 --- a/pelican/tools/templates/pelicanconf.py.jinja2 +++ b/pelican/tools/templates/pelicanconf.py.jinja2 @@ -1,8 +1,8 @@ AUTHOR = {{author}} SITENAME = {{sitename}} -SITEURL = '' +SITEURL = "" -PATH = 'content' +PATH = "content" TIMEZONE = {{timezone}} @@ -16,16 +16,20 @@ AUTHOR_FEED_ATOM = None AUTHOR_FEED_RSS = None # Blogroll -LINKS = (('Pelican', 'https://getpelican.com/'), - ('Python.org', 'https://www.python.org/'), - ('Jinja2', 'https://palletsprojects.com/p/jinja/'), - ('You can modify those links in your config file', '#'),) +LINKS = ( + ("Pelican", "https://getpelican.com/"), + ("Python.org", "https://www.python.org/"), + ("Jinja2", "https://palletsprojects.com/p/jinja/"), + ("You can modify those links in your config file", "#"), +) # Social widget -SOCIAL = (('You can add links in your config file', '#'), - ('Another social link', '#'),) +SOCIAL = ( + ("You can add links in your config file", "#"), + ("Another social link", "#"), +) DEFAULT_PAGINATION = {{default_pagination}} # Uncomment following line if you want document-relative URLs when developing -#RELATIVE_URLS = True +# RELATIVE_URLS = True diff --git a/pelican/tools/templates/publishconf.py.jinja2 b/pelican/tools/templates/publishconf.py.jinja2 index e119222c..301e4dfa 100755 --- a/pelican/tools/templates/publishconf.py.jinja2 +++ b/pelican/tools/templates/publishconf.py.jinja2 @@ -3,19 +3,20 @@ import os import sys + sys.path.append(os.curdir) from pelicanconf import * # If your site is available via HTTPS, make sure SITEURL begins with https:// -SITEURL = '{{siteurl}}' +SITEURL = "{{siteurl}}" RELATIVE_URLS = False -FEED_ALL_ATOM = 'feeds/all.atom.xml' -CATEGORY_FEED_ATOM = 'feeds/{slug}.atom.xml' +FEED_ALL_ATOM = "feeds/all.atom.xml" +CATEGORY_FEED_ATOM = "feeds/{slug}.atom.xml" DELETE_OUTPUT_DIRECTORY = True # Following items are often useful when publishing -#DISQUS_SITENAME = "" -#GOOGLE_ANALYTICS = "" +# DISQUS_SITENAME = "" +# GOOGLE_ANALYTICS = "" diff --git a/pelican/tools/templates/tasks.py.jinja2 b/pelican/tools/templates/tasks.py.jinja2 index f3caed56..1a0f02d1 100644 --- a/pelican/tools/templates/tasks.py.jinja2 +++ b/pelican/tools/templates/tasks.py.jinja2 @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import os import shlex import shutil @@ -14,61 +12,66 @@ from pelican.server import ComplexHTTPRequestHandler, RootedHTTPServer from pelican.settings import DEFAULT_CONFIG, get_settings_from_file OPEN_BROWSER_ON_SERVE = True -SETTINGS_FILE_BASE = 'pelicanconf.py' +SETTINGS_FILE_BASE = "pelicanconf.py" SETTINGS = {} SETTINGS.update(DEFAULT_CONFIG) LOCAL_SETTINGS = get_settings_from_file(SETTINGS_FILE_BASE) SETTINGS.update(LOCAL_SETTINGS) CONFIG = { - 'settings_base': SETTINGS_FILE_BASE, - 'settings_publish': 'publishconf.py', + "settings_base": SETTINGS_FILE_BASE, + "settings_publish": "publishconf.py", # Output path. Can be absolute or relative to tasks.py. Default: 'output' - 'deploy_path': SETTINGS['OUTPUT_PATH'], + "deploy_path": SETTINGS["OUTPUT_PATH"], {% if ssh %} # Remote server configuration - 'ssh_user': '{{ssh_user}}', - 'ssh_host': '{{ssh_host}}', - 'ssh_port': '{{ssh_port}}', - 'ssh_path': '{{ssh_target_dir}}', + "ssh_user": "{{ssh_user}}", + "ssh_host": "{{ssh_host}}", + "ssh_port": "{{ssh_port}}", + "ssh_path": "{{ssh_target_dir}}", {% endif %} {% if cloudfiles %} # Rackspace Cloud Files configuration settings - 'cloudfiles_username': '{{cloudfiles_username}}', - 'cloudfiles_api_key': '{{cloudfiles_api_key}}', - 'cloudfiles_container': '{{cloudfiles_container}}', + "cloudfiles_username": "{{cloudfiles_username}}", + "cloudfiles_api_key": "{{cloudfiles_api_key}}", + "cloudfiles_container": "{{cloudfiles_container}}", {% endif %} {% if github %} # Github Pages configuration - 'github_pages_branch': '{{github_pages_branch}}', - 'commit_message': "'Publish site on {}'".format(datetime.date.today().isoformat()), + "github_pages_branch": "{{github_pages_branch}}", + "commit_message": f"'Publish site on {datetime.date.today().isoformat()}'", {% endif %} # Host and port for `serve` - 'host': 'localhost', - 'port': 8000, + "host": "localhost", + "port": 8000, } + @task def clean(c): """Remove generated files""" - if os.path.isdir(CONFIG['deploy_path']): - shutil.rmtree(CONFIG['deploy_path']) - os.makedirs(CONFIG['deploy_path']) + if os.path.isdir(CONFIG["deploy_path"]): + shutil.rmtree(CONFIG["deploy_path"]) + os.makedirs(CONFIG["deploy_path"]) + @task def build(c): """Build local version of site""" - pelican_run('-s {settings_base}'.format(**CONFIG)) + pelican_run("-s {settings_base}".format(**CONFIG)) + @task def rebuild(c): """`build` with the delete switch""" - pelican_run('-d -s {settings_base}'.format(**CONFIG)) + pelican_run("-d -s {settings_base}".format(**CONFIG)) + @task def regenerate(c): """Automatically regenerate site upon file modification""" - pelican_run('-r -s {settings_base}'.format(**CONFIG)) + pelican_run("-r -s {settings_base}".format(**CONFIG)) + @task def serve(c): @@ -78,28 +81,32 @@ def serve(c): allow_reuse_address = True server = AddressReuseTCPServer( - CONFIG['deploy_path'], - (CONFIG['host'], CONFIG['port']), - ComplexHTTPRequestHandler) + CONFIG["deploy_path"], + (CONFIG["host"], CONFIG["port"]), + ComplexHTTPRequestHandler, + ) if OPEN_BROWSER_ON_SERVE: # Open site in default browser import webbrowser + webbrowser.open("http://{host}:{port}".format(**CONFIG)) - sys.stderr.write('Serving at {host}:{port} ...\n'.format(**CONFIG)) + sys.stderr.write("Serving at {host}:{port} ...\n".format(**CONFIG)) server.serve_forever() + @task def reserve(c): """`build`, then `serve`""" build(c) serve(c) + @task def preview(c): """Build production version of site""" - pelican_run('-s {settings_publish}'.format(**CONFIG)) + pelican_run("-s {settings_publish}".format(**CONFIG)) @task def livereload(c): @@ -107,25 +114,25 @@ def livereload(c): from livereload import Server def cached_build(): - cmd = '-s {settings_base} -e CACHE_CONTENT=true LOAD_CONTENT_CACHE=true' + cmd = "-s {settings_base} -e CACHE_CONTENT=true LOAD_CONTENT_CACHE=true" pelican_run(cmd.format(**CONFIG)) cached_build() server = Server() - theme_path = SETTINGS['THEME'] + theme_path = SETTINGS["THEME"] watched_globs = [ - CONFIG['settings_base'], - '{}/templates/**/*.html'.format(theme_path), + CONFIG["settings_base"], + f"{theme_path}/templates/**/*.html", ] - content_file_extensions = ['.md', '.rst'] + content_file_extensions = [".md", ".rst"] for extension in content_file_extensions: - content_glob = '{0}/**/*{1}'.format(SETTINGS['PATH'], extension) + content_glob = "{}/**/*{}".format(SETTINGS["PATH"], extension) watched_globs.append(content_glob) - static_file_extensions = ['.css', '.js'] + static_file_extensions = [".css", ".js"] for extension in static_file_extensions: - static_file_glob = '{0}/static/**/*{1}'.format(theme_path, extension) + static_file_glob = f"{theme_path}/static/**/*{extension}" watched_globs.append(static_file_glob) for glob in watched_globs: @@ -134,43 +141,49 @@ def livereload(c): if OPEN_BROWSER_ON_SERVE: # Open site in default browser import webbrowser + webbrowser.open("http://{host}:{port}".format(**CONFIG)) - server.serve(host=CONFIG['host'], port=CONFIG['port'], root=CONFIG['deploy_path']) + server.serve(host=CONFIG["host"], port=CONFIG["port"], root=CONFIG["deploy_path"]) {% if cloudfiles %} @task def cf_upload(c): """Publish to Rackspace Cloud Files""" rebuild(c) - with cd(CONFIG['deploy_path']): - c.run('swift -v -A https://auth.api.rackspacecloud.com/v1.0 ' - '-U {cloudfiles_username} ' - '-K {cloudfiles_api_key} ' - 'upload -c {cloudfiles_container} .'.format(**CONFIG)) + with cd(CONFIG["deploy_path"]): + c.run( + "swift -v -A https://auth.api.rackspacecloud.com/v1.0 " + "-U {cloudfiles_username} " + "-K {cloudfiles_api_key} " + "upload -c {cloudfiles_container} .".format(**CONFIG) + ) {% endif %} @task def publish(c): """Publish to production via rsync""" - pelican_run('-s {settings_publish}'.format(**CONFIG)) + pelican_run("-s {settings_publish}".format(**CONFIG)) c.run( 'rsync --delete --exclude ".DS_Store" -pthrvz -c ' '-e "ssh -p {ssh_port}" ' - '{} {ssh_user}@{ssh_host}:{ssh_path}'.format( - CONFIG['deploy_path'].rstrip('/') + '/', - **CONFIG)) + "{} {ssh_user}@{ssh_host}:{ssh_path}".format( + CONFIG["deploy_path"].rstrip("/") + "/", **CONFIG + ) + ) {% if github %} @task def gh_pages(c): """Publish to GitHub Pages""" preview(c) - c.run('ghp-import -b {github_pages_branch} ' - '-m {commit_message} ' - '{deploy_path} -p'.format(**CONFIG)) + c.run( + "ghp-import -b {github_pages_branch} " + "-m {commit_message} " + "{deploy_path} -p".format(**CONFIG) + ) {% endif %} def pelican_run(cmd): - cmd += ' ' + program.core.remainder # allows to pass-through args to pelican + cmd += " " + program.core.remainder # allows to pass-through args to pelican pelican_main(shlex.split(cmd)) diff --git a/pelican/urlwrappers.py b/pelican/urlwrappers.py index efe09fbc..6d705d4c 100644 --- a/pelican/urlwrappers.py +++ b/pelican/urlwrappers.py @@ -1,6 +1,7 @@ import functools import logging import os +import pathlib from pelican.utils import slugify @@ -30,17 +31,16 @@ class URLWrapper: @property def slug(self): if self._slug is None: - class_key = '{}_REGEX_SUBSTITUTIONS'.format( - self.__class__.__name__.upper()) + class_key = f"{self.__class__.__name__.upper()}_REGEX_SUBSTITUTIONS" regex_subs = self.settings.get( - class_key, - self.settings.get('SLUG_REGEX_SUBSTITUTIONS', [])) - preserve_case = self.settings.get('SLUGIFY_PRESERVE_CASE', False) + class_key, self.settings.get("SLUG_REGEX_SUBSTITUTIONS", []) + ) + preserve_case = self.settings.get("SLUGIFY_PRESERVE_CASE", False) self._slug = slugify( self.name, regex_subs=regex_subs, preserve_case=preserve_case, - use_unicode=self.settings.get('SLUGIFY_USE_UNICODE', False) + use_unicode=self.settings.get("SLUGIFY_USE_UNICODE", False), ) return self._slug @@ -52,26 +52,26 @@ class URLWrapper: def as_dict(self): d = self.__dict__ - d['name'] = self.name - d['slug'] = self.slug + d["name"] = self.name + d["slug"] = self.slug return d def __hash__(self): return hash(self.slug) def _normalize_key(self, key): - class_key = '{}_REGEX_SUBSTITUTIONS'.format( - self.__class__.__name__.upper()) + class_key = f"{self.__class__.__name__.upper()}_REGEX_SUBSTITUTIONS" regex_subs = self.settings.get( - class_key, - self.settings.get('SLUG_REGEX_SUBSTITUTIONS', [])) - use_unicode = self.settings.get('SLUGIFY_USE_UNICODE', False) - preserve_case = self.settings.get('SLUGIFY_PRESERVE_CASE', False) + class_key, self.settings.get("SLUG_REGEX_SUBSTITUTIONS", []) + ) + use_unicode = self.settings.get("SLUGIFY_USE_UNICODE", False) + preserve_case = self.settings.get("SLUGIFY_PRESERVE_CASE", False) return slugify( key, regex_subs=regex_subs, preserve_case=preserve_case, - use_unicode=use_unicode) + use_unicode=use_unicode, + ) def __eq__(self, other): if isinstance(other, self.__class__): @@ -98,7 +98,7 @@ class URLWrapper: return self.name def __repr__(self): - return '<{} {}>'.format(type(self).__name__, repr(self._name)) + return f"<{type(self).__name__} {repr(self._name)}>" def _from_settings(self, key, get_page_name=False): """Returns URL information as defined in settings. @@ -108,10 +108,12 @@ class URLWrapper: "cat/{slug}" Useful for pagination. """ - setting = "{}_{}".format(self.__class__.__name__.upper(), key) + setting = f"{self.__class__.__name__.upper()}_{key}" value = self.settings[setting] + if isinstance(value, pathlib.Path): + value = str(value) if not isinstance(value, str): - logger.warning('%s is set to %s', setting, value) + logger.warning("%s is set to %s", setting, value) return value else: if get_page_name: @@ -119,10 +121,11 @@ class URLWrapper: else: return value.format(**self.as_dict()) - page_name = property(functools.partial(_from_settings, key='URL', - get_page_name=True)) - url = property(functools.partial(_from_settings, key='URL')) - save_as = property(functools.partial(_from_settings, key='SAVE_AS')) + page_name = property( + functools.partial(_from_settings, key="URL", get_page_name=True) + ) + url = property(functools.partial(_from_settings, key="URL")) + save_as = property(functools.partial(_from_settings, key="SAVE_AS")) class Category(URLWrapper): diff --git a/pelican/utils.py b/pelican/utils.py index d8cf15b4..eda53d3f 100644 --- a/pelican/utils.py +++ b/pelican/utils.py @@ -3,6 +3,7 @@ import fnmatch import locale import logging import os +import pathlib import re import shutil import sys @@ -24,43 +25,42 @@ except ModuleNotFoundError: from backports.zoneinfo import ZoneInfo from markupsafe import Markup +import watchfiles + logger = logging.getLogger(__name__) def sanitised_join(base_directory, *parts): - joined = posixize_path( - os.path.abspath(os.path.join(base_directory, *parts))) + joined = posixize_path(os.path.abspath(os.path.join(base_directory, *parts))) base = posixize_path(os.path.abspath(base_directory)) if not joined.startswith(base): - raise RuntimeError( - "Attempted to break out of output directory to {}".format( - joined - ) - ) + raise RuntimeError(f"Attempted to break out of output directory to {joined}") return joined def strftime(date, date_format): - ''' + """ Enhanced replacement for built-in strftime with zero stripping This works by 'grabbing' possible format strings (those starting with %), formatting them with the date, stripping any leading zeros if - prefix is used and replacing formatted output back. - ''' + """ + def strip_zeros(x): - return x.lstrip('0') or '0' + return x.lstrip("0") or "0" + # includes ISO date parameters added by Python 3.6 - c89_directives = 'aAbBcdfGHIjmMpSUuVwWxXyYzZ%' + c89_directives = "aAbBcdfGHIjmMpSUuVwWxXyYzZ%" # grab candidate format options - format_options = '%[-]?.' + format_options = "%[-]?." candidates = re.findall(format_options, date_format) # replace candidates with placeholders for later % formatting - template = re.sub(format_options, '%s', date_format) + template = re.sub(format_options, "%s", date_format) formatted_candidates = [] for candidate in candidates: @@ -69,7 +69,7 @@ def strftime(date, date_format): # check for '-' prefix if len(candidate) == 3: # '-' prefix - candidate = '%{}'.format(candidate[-1]) + candidate = f"%{candidate[-1]}" conversion = strip_zeros else: conversion = None @@ -92,10 +92,10 @@ def strftime(date, date_format): class SafeDatetime(datetime.datetime): - '''Subclass of datetime that works with utf-8 format strings on PY2''' + """Subclass of datetime that works with utf-8 format strings on PY2""" def strftime(self, fmt, safe=True): - '''Uses our custom strftime if supposed to be *safe*''' + """Uses our custom strftime if supposed to be *safe*""" if safe: return strftime(self, fmt) else: @@ -103,28 +103,23 @@ class SafeDatetime(datetime.datetime): class DateFormatter: - '''A date formatter object used as a jinja filter + """A date formatter object used as a jinja filter Uses the `strftime` implementation and makes sure jinja uses the locale defined in LOCALE setting - ''' + """ def __init__(self): self.locale = locale.setlocale(locale.LC_TIME) def __call__(self, date, date_format): - old_lc_time = locale.setlocale(locale.LC_TIME) - old_lc_ctype = locale.setlocale(locale.LC_CTYPE) - - locale.setlocale(locale.LC_TIME, self.locale) # on OSX, encoding from LC_CTYPE determines the unicode output in PY3 # make sure it's same as LC_TIME - locale.setlocale(locale.LC_CTYPE, self.locale) + with temporary_locale(self.locale, locale.LC_TIME), temporary_locale( + self.locale, locale.LC_CTYPE + ): + formatted = strftime(date, date_format) - formatted = strftime(date, date_format) - - locale.setlocale(locale.LC_TIME, old_lc_time) - locale.setlocale(locale.LC_CTYPE, old_lc_ctype) return formatted @@ -156,7 +151,7 @@ class memoized: return self.func.__doc__ def __get__(self, obj, objtype): - '''Support instance methods.''' + """Support instance methods.""" fn = partial(self.__call__, obj) fn.cache = self.cache return fn @@ -178,17 +173,16 @@ def deprecated_attribute(old, new, since=None, remove=None, doc=None): Note that the decorator needs a dummy method to attach to, but the content of the dummy method is ignored. """ + def _warn(): - version = '.'.join(str(x) for x in since) - message = ['{} has been deprecated since {}'.format(old, version)] + version = ".".join(str(x) for x in since) + message = [f"{old} has been deprecated since {version}"] if remove: - version = '.'.join(str(x) for x in remove) - message.append( - ' and will be removed by version {}'.format(version)) - message.append('. Use {} instead.'.format(new)) - logger.warning(''.join(message)) - logger.debug(''.join(str(x) for x - in traceback.format_stack())) + version = ".".join(str(x) for x in remove) + message.append(f" and will be removed by version {version}") + message.append(f". Use {new} instead.") + logger.warning("".join(message)) + logger.debug("".join(str(x) for x in traceback.format_stack())) def fget(self): _warn() @@ -209,21 +203,20 @@ def get_date(string): If no format matches the given date, raise a ValueError. """ - string = re.sub(' +', ' ', string) - default = SafeDatetime.now().replace(hour=0, minute=0, - second=0, microsecond=0) + string = re.sub(" +", " ", string) + default = SafeDatetime.now().replace(hour=0, minute=0, second=0, microsecond=0) try: return dateutil.parser.parse(string, default=default) except (TypeError, ValueError): - raise ValueError('{!r} is not a valid date'.format(string)) + raise ValueError(f"{string!r} is not a valid date") @contextmanager -def pelican_open(filename, mode='r', strip_crs=(sys.platform == 'win32')): +def pelican_open(filename, mode="r", strip_crs=(sys.platform == "win32")): """Open a file and return its content""" # utf-8-sig will clear any BOM if present - with open(filename, mode, encoding='utf-8-sig') as infile: + with open(filename, mode, encoding="utf-8-sig") as infile: content = infile.read() yield content @@ -245,7 +238,7 @@ def slugify(value, regex_subs=(), preserve_case=False, use_unicode=False): def normalize_unicode(text): # normalize text by compatibility composition # see: https://en.wikipedia.org/wiki/Unicode_equivalence - return unicodedata.normalize('NFKC', text) + return unicodedata.normalize("NFKC", text) # strip tags from value value = Markup(value).striptags() @@ -260,10 +253,8 @@ def slugify(value, regex_subs=(), preserve_case=False, use_unicode=False): # perform regex substitutions for src, dst in regex_subs: value = re.sub( - normalize_unicode(src), - normalize_unicode(dst), - value, - flags=re.IGNORECASE) + normalize_unicode(src), normalize_unicode(dst), value, flags=re.IGNORECASE + ) if not preserve_case: value = value.lower() @@ -284,8 +275,7 @@ def copy(source, destination, ignores=None): """ def walk_error(err): - logger.warning("While copying %s: %s: %s", - source_, err.filename, err.strerror) + logger.warning("While copying %s: %s: %s", source_, err.filename, err.strerror) source_ = os.path.abspath(os.path.expanduser(source)) destination_ = os.path.abspath(os.path.expanduser(destination)) @@ -293,39 +283,40 @@ def copy(source, destination, ignores=None): if ignores is None: ignores = [] - if any(fnmatch.fnmatch(os.path.basename(source), ignore) - for ignore in ignores): - logger.info('Not copying %s due to ignores', source_) + if any(fnmatch.fnmatch(os.path.basename(source), ignore) for ignore in ignores): + logger.info("Not copying %s due to ignores", source_) return if os.path.isfile(source_): dst_dir = os.path.dirname(destination_) if not os.path.exists(dst_dir): - logger.info('Creating directory %s', dst_dir) + logger.info("Creating directory %s", dst_dir) os.makedirs(dst_dir) - logger.info('Copying %s to %s', source_, destination_) - copy_file_metadata(source_, destination_) + logger.info("Copying %s to %s", source_, destination_) + copy_file(source_, destination_) elif os.path.isdir(source_): if not os.path.exists(destination_): - logger.info('Creating directory %s', destination_) + logger.info("Creating directory %s", destination_) os.makedirs(destination_) if not os.path.isdir(destination_): - logger.warning('Cannot copy %s (a directory) to %s (a file)', - source_, destination_) + logger.warning( + "Cannot copy %s (a directory) to %s (a file)", source_, destination_ + ) return for src_dir, subdirs, others in os.walk(source_, followlinks=True): - dst_dir = os.path.join(destination_, - os.path.relpath(src_dir, source_)) + dst_dir = os.path.join(destination_, os.path.relpath(src_dir, source_)) - subdirs[:] = (s for s in subdirs if not any(fnmatch.fnmatch(s, i) - for i in ignores)) - others[:] = (o for o in others if not any(fnmatch.fnmatch(o, i) - for i in ignores)) + subdirs[:] = ( + s for s in subdirs if not any(fnmatch.fnmatch(s, i) for i in ignores) + ) + others[:] = ( + o for o in others if not any(fnmatch.fnmatch(o, i) for i in ignores) + ) if not os.path.isdir(dst_dir): - logger.info('Creating directory %s', dst_dir) + logger.info("Creating directory %s", dst_dir) # Parent directories are known to exist, so 'mkdir' suffices. os.mkdir(dst_dir) @@ -333,24 +324,24 @@ def copy(source, destination, ignores=None): src_path = os.path.join(src_dir, o) dst_path = os.path.join(dst_dir, o) if os.path.isfile(src_path): - logger.info('Copying %s to %s', src_path, dst_path) - copy_file_metadata(src_path, dst_path) + logger.info("Copying %s to %s", src_path, dst_path) + copy_file(src_path, dst_path) else: - logger.warning('Skipped copy %s (not a file or ' - 'directory) to %s', - src_path, dst_path) + logger.warning( + "Skipped copy %s (not a file or " "directory) to %s", + src_path, + dst_path, + ) -def copy_file_metadata(source, destination): - '''Copy a file and its metadata (perm bits, access times, ...)''' - - # This function is a workaround for Android python copystat - # bug ([issue28141]) https://bugs.python.org/issue28141 +def copy_file(source, destination): + """Copy a file""" try: - shutil.copy2(source, destination) + shutil.copyfile(source, destination) except OSError as e: - logger.warning("A problem occurred copying file %s to %s; %s", - source, destination, e) + logger.warning( + "A problem occurred copying file %s to %s; %s", source, destination, e + ) def clean_output_dir(path, retention): @@ -371,15 +362,15 @@ def clean_output_dir(path, retention): for filename in os.listdir(path): file = os.path.join(path, filename) if any(filename == retain for retain in retention): - logger.debug("Skipping deletion; %s is on retention list: %s", - filename, file) + logger.debug( + "Skipping deletion; %s is on retention list: %s", filename, file + ) elif os.path.isdir(file): try: shutil.rmtree(file) logger.debug("Deleted directory %s", file) except Exception as e: - logger.error("Unable to delete directory %s; %s", - file, e) + logger.error("Unable to delete directory %s; %s", file, e) elif os.path.isfile(file) or os.path.islink(file): try: os.remove(file) @@ -411,29 +402,31 @@ def posixize_path(rel_path): """Use '/' as path separator, so that source references, like '{static}/foo/bar.jpg' or 'extras/favicon.ico', will work on Windows as well as on Mac and Linux.""" - return rel_path.replace(os.sep, '/') + return rel_path.replace(os.sep, "/") class _HTMLWordTruncator(HTMLParser): - - _word_regex = re.compile(r"{DBC}|(\w[\w'-]*)".format( - # DBC means CJK-like characters. An character can stand for a word. - DBC=("([\u4E00-\u9FFF])|" # CJK Unified Ideographs - "([\u3400-\u4DBF])|" # CJK Unified Ideographs Extension A - "([\uF900-\uFAFF])|" # CJK Compatibility Ideographs - "([\U00020000-\U0002A6DF])|" # CJK Unified Ideographs Extension B - "([\U0002F800-\U0002FA1F])|" # CJK Compatibility Ideographs Supplement - "([\u3040-\u30FF])|" # Hiragana and Katakana - "([\u1100-\u11FF])|" # Hangul Jamo - "([\uAC00-\uD7FF])|" # Hangul Compatibility Jamo - "([\u3130-\u318F])" # Hangul Syllables - )), re.UNICODE) - _word_prefix_regex = re.compile(r'\w', re.U) - _singlets = ('br', 'col', 'link', 'base', 'img', 'param', 'area', - 'hr', 'input') + _word_regex = re.compile( + r"{DBC}|(\w[\w'-]*)".format( + # DBC means CJK-like characters. An character can stand for a word. + DBC=( + "([\u4E00-\u9FFF])|" # CJK Unified Ideographs + "([\u3400-\u4DBF])|" # CJK Unified Ideographs Extension A + "([\uF900-\uFAFF])|" # CJK Compatibility Ideographs + "([\U00020000-\U0002A6DF])|" # CJK Unified Ideographs Extension B + "([\U0002F800-\U0002FA1F])|" # CJK Compatibility Ideographs Supplement + "([\u3040-\u30FF])|" # Hiragana and Katakana + "([\u1100-\u11FF])|" # Hangul Jamo + "([\uAC00-\uD7FF])|" # Hangul Compatibility Jamo + "([\u3130-\u318F])" # Hangul Syllables + ) + ), + re.UNICODE, + ) + _word_prefix_regex = re.compile(r"\w", re.U) + _singlets = ("br", "col", "link", "base", "img", "param", "area", "hr", "input") class TruncationCompleted(Exception): - def __init__(self, truncate_at): super().__init__(truncate_at) self.truncate_at = truncate_at @@ -459,7 +452,7 @@ class _HTMLWordTruncator(HTMLParser): line_start = 0 lineno, line_offset = self.getpos() for i in range(lineno - 1): - line_start = self.rawdata.index('\n', line_start) + 1 + line_start = self.rawdata.index("\n", line_start) + 1 return line_start + line_offset def add_word(self, word_end): @@ -486,7 +479,7 @@ class _HTMLWordTruncator(HTMLParser): else: # SGML: An end tag closes, back to the matching start tag, # all unclosed intervening start tags with omitted end tags - del self.open_tags[:i + 1] + del self.open_tags[: i + 1] def handle_data(self, data): word_end = 0 @@ -535,7 +528,7 @@ class _HTMLWordTruncator(HTMLParser): ref_end = offset + len(name) + 1 try: - if self.rawdata[ref_end] == ';': + if self.rawdata[ref_end] == ";": ref_end += 1 except IndexError: # We are at the end of the string and there's no ';' @@ -560,7 +553,7 @@ class _HTMLWordTruncator(HTMLParser): codepoint = entities.name2codepoint[name] char = chr(codepoint) except KeyError: - char = '' + char = "" self._handle_ref(name, char) def handle_charref(self, name): @@ -571,17 +564,17 @@ class _HTMLWordTruncator(HTMLParser): `#x2014`) """ try: - if name.startswith('x'): + if name.startswith("x"): codepoint = int(name[1:], 16) else: codepoint = int(name) char = chr(codepoint) except (ValueError, OverflowError): - char = '' - self._handle_ref('#' + name, char) + char = "" + self._handle_ref("#" + name, char) -def truncate_html_words(s, num, end_text='…'): +def truncate_html_words(s, num, end_text="…"): """Truncates HTML to a certain number of words. (not counting tags and comments). Closes opened tags if they were correctly @@ -592,23 +585,23 @@ def truncate_html_words(s, num, end_text='…'): """ length = int(num) if length <= 0: - return '' + return "" truncator = _HTMLWordTruncator(length) truncator.feed(s) if truncator.truncate_at is None: return s - out = s[:truncator.truncate_at] + out = s[: truncator.truncate_at] if end_text: - out += ' ' + end_text + out += " " + end_text # Close any tags still open for tag in truncator.open_tags: - out += '' % tag + out += "" % tag # Return string return out def process_translations(content_list, translation_id=None): - """ Finds translations and returns them. + """Finds translations and returns them. For each content_list item, populates the 'translations' attribute, and returns a tuple with two lists (index, translations). Index list includes @@ -636,19 +629,23 @@ def process_translations(content_list, translation_id=None): try: content_list.sort(key=attrgetter(*translation_id)) except TypeError: - raise TypeError('Cannot unpack {}, \'translation_id\' must be falsy, a' - ' string or a collection of strings' - .format(translation_id)) + raise TypeError( + "Cannot unpack {}, 'translation_id' must be falsy, a" + " string or a collection of strings".format(translation_id) + ) except AttributeError: - raise AttributeError('Cannot use {} as \'translation_id\', there ' - 'appear to be items without these metadata ' - 'attributes'.format(translation_id)) + raise AttributeError( + "Cannot use {} as 'translation_id', there " + "appear to be items without these metadata " + "attributes".format(translation_id) + ) for id_vals, items in groupby(content_list, attrgetter(*translation_id)): # prepare warning string id_vals = (id_vals,) if len(translation_id) == 1 else id_vals - with_str = 'with' + ', '.join([' {} "{{}}"'] * len(translation_id))\ - .format(*translation_id).format(*id_vals) + with_str = "with" + ", ".join([' {} "{{}}"'] * len(translation_id)).format( + *translation_id + ).format(*id_vals) items = list(items) original_items = get_original_items(items, with_str) @@ -666,24 +663,24 @@ def get_original_items(items, with_str): args = [len(items)] args.extend(extra) args.extend(x.source_path for x in items) - logger.warning('{}: {}'.format(msg, '\n%s' * len(items)), *args) + logger.warning("{}: {}".format(msg, "\n%s" * len(items)), *args) # warn if several items have the same lang - for lang, lang_items in groupby(items, attrgetter('lang')): + for lang, lang_items in groupby(items, attrgetter("lang")): lang_items = list(lang_items) if len(lang_items) > 1: - _warn_source_paths('There are %s items "%s" with lang %s', - lang_items, with_str, lang) + _warn_source_paths( + 'There are %s items "%s" with lang %s', lang_items, with_str, lang + ) # items with `translation` metadata will be used as translations... candidate_items = [ - i for i in items - if i.metadata.get('translation', 'false').lower() == 'false'] + i for i in items if i.metadata.get("translation", "false").lower() == "false" + ] # ...unless all items with that slug are translations if not candidate_items: - _warn_source_paths('All items ("%s") "%s" are translations', - items, with_str) + _warn_source_paths('All items ("%s") "%s" are translations', items, with_str) candidate_items = items # find items with default language @@ -695,13 +692,14 @@ def get_original_items(items, with_str): # warn if there are several original items if len(original_items) > 1: - _warn_source_paths('There are %s original (not translated) items %s', - original_items, with_str) + _warn_source_paths( + "There are %s original (not translated) items %s", original_items, with_str + ) return original_items -def order_content(content_list, order_by='slug'): - """ Sorts content. +def order_content(content_list, order_by="slug"): + """Sorts content. order_by can be a string of an attribute or sorting function. If order_by is defined, content will be ordered by that attribute or sorting function. @@ -717,22 +715,22 @@ def order_content(content_list, order_by='slug'): try: content_list.sort(key=order_by) except Exception: - logger.error('Error sorting with function %s', order_by) + logger.error("Error sorting with function %s", order_by) elif isinstance(order_by, str): - if order_by.startswith('reversed-'): + if order_by.startswith("reversed-"): order_reversed = True - order_by = order_by.replace('reversed-', '', 1) + order_by = order_by.replace("reversed-", "", 1) else: order_reversed = False - if order_by == 'basename': + if order_by == "basename": content_list.sort( - key=lambda x: os.path.basename(x.source_path or ''), - reverse=order_reversed) + key=lambda x: os.path.basename(x.source_path or ""), + reverse=order_reversed, + ) else: try: - content_list.sort(key=attrgetter(order_by), - reverse=order_reversed) + content_list.sort(key=attrgetter(order_by), reverse=order_reversed) except AttributeError: for content in content_list: try: @@ -740,182 +738,60 @@ def order_content(content_list, order_by='slug'): except AttributeError: logger.warning( 'There is no "%s" attribute in "%s". ' - 'Defaulting to slug order.', + "Defaulting to slug order.", order_by, content.get_relative_source_path(), extra={ - 'limit_msg': ('More files are missing ' - 'the needed attribute.') - }) + "limit_msg": ( + "More files are missing " + "the needed attribute." + ) + }, + ) else: logger.warning( - 'Invalid *_ORDER_BY setting (%s). ' - 'Valid options are strings and functions.', order_by) + "Invalid *_ORDER_BY setting (%s). " + "Valid options are strings and functions.", + order_by, + ) return content_list -class FileSystemWatcher: - def __init__(self, settings_file, reader_class, settings=None): - self.watchers = { - 'settings': FileSystemWatcher.file_watcher(settings_file) - } +def wait_for_changes(settings_file, reader_class, settings): + content_path = settings.get("PATH", "") + theme_path = settings.get("THEME", "") + ignore_files = { + fnmatch.translate(pattern) for pattern in settings.get("IGNORE_FILES", []) + } - self.settings = None - self.reader_class = reader_class - self._extensions = None - self._content_path = None - self._theme_path = None - self._ignore_files = None + candidate_paths = [ + settings_file, + theme_path, + content_path, + ] - if settings is not None: - self.update_watchers(settings) + candidate_paths.extend( + os.path.join(content_path, path) for path in settings.get("STATIC_PATHS", []) + ) - def update_watchers(self, settings): - new_extensions = set(self.reader_class(settings).extensions) - new_content_path = settings.get('PATH', '') - new_theme_path = settings.get('THEME', '') - new_ignore_files = set(settings.get('IGNORE_FILES', [])) - - extensions_changed = new_extensions != self._extensions - content_changed = new_content_path != self._content_path - theme_changed = new_theme_path != self._theme_path - ignore_changed = new_ignore_files != self._ignore_files - - # Refresh content watcher if related settings changed - if extensions_changed or content_changed or ignore_changed: - self.add_watcher('content', - new_content_path, - new_extensions, - new_ignore_files) - - # Refresh theme watcher if related settings changed - if theme_changed or ignore_changed: - self.add_watcher('theme', - new_theme_path, - [''], - new_ignore_files) - - # Watch STATIC_PATHS - old_static_watchers = set(key - for key in self.watchers - if key.startswith('[static]')) - - for path in settings.get('STATIC_PATHS', []): - key = '[static]{}'.format(path) - if ignore_changed or (key not in self.watchers): - self.add_watcher( - key, - os.path.join(new_content_path, path), - [''], - new_ignore_files) - if key in old_static_watchers: - old_static_watchers.remove(key) - - # cleanup removed static watchers - for key in old_static_watchers: - del self.watchers[key] - - # update values - self.settings = settings - self._extensions = new_extensions - self._content_path = new_content_path - self._theme_path = new_theme_path - self._ignore_files = new_ignore_files - - def check(self): - '''return a key:watcher_status dict for all watchers''' - result = {key: next(watcher) for key, watcher in self.watchers.items()} - - # Various warnings - if result.get('content') is None: - reader_descs = sorted( - { - ' | %s (%s)' % (type(r).__name__, ', '.join(r.file_extensions)) - for r in self.reader_class(self.settings).readers.values() - if r.enabled - } - ) - logger.warning( - 'No valid files found in content for the active readers:\n' - + '\n'.join(reader_descs)) - - if result.get('theme') is None: - logger.warning('Empty theme folder. Using `basic` theme.') - - return result - - def add_watcher(self, key, path, extensions=[''], ignores=[]): - watcher = self.get_watcher(path, extensions, ignores) - if watcher is not None: - self.watchers[key] = watcher - - def get_watcher(self, path, extensions=[''], ignores=[]): - '''return a watcher depending on path type (file or folder)''' + watching_paths = [] + for path in candidate_paths: + if not path: + continue + path = os.path.abspath(path) if not os.path.exists(path): - logger.warning("Watched path does not exist: %s", path) - return None - - if os.path.isdir(path): - return self.folder_watcher(path, extensions, ignores) + logger.warning("Unable to watch path '%s' as it does not exist.", path) else: - return self.file_watcher(path) + watching_paths.append(path) - @staticmethod - def folder_watcher(path, extensions, ignores=[]): - '''Generator for monitoring a folder for modifications. - - Returns a boolean indicating if files are changed since last check. - Returns None if there are no matching files in the folder''' - - def file_times(path): - '''Return `mtime` for each file in path''' - - for root, dirs, files in os.walk(path, followlinks=True): - dirs[:] = [x for x in dirs if not x.startswith(os.curdir)] - - for f in files: - valid_extension = f.endswith(tuple(extensions)) - file_ignored = any( - fnmatch.fnmatch(f, ignore) for ignore in ignores - ) - if valid_extension and not file_ignored: - try: - yield os.stat(os.path.join(root, f)).st_mtime - except OSError as e: - logger.warning('Caught Exception: %s', e) - - LAST_MTIME = 0 - while True: - try: - mtime = max(file_times(path)) - if mtime > LAST_MTIME: - LAST_MTIME = mtime - yield True - except ValueError: - yield None - else: - yield False - - @staticmethod - def file_watcher(path): - '''Generator for monitoring a file for modifications''' - LAST_MTIME = 0 - while True: - if path: - try: - mtime = os.stat(path).st_mtime - except OSError as e: - logger.warning('Caught Exception: %s', e) - continue - - if mtime > LAST_MTIME: - LAST_MTIME = mtime - yield True - else: - yield False - else: - yield None + return next( + watchfiles.watch( + *watching_paths, + watch_filter=watchfiles.DefaultFilter(ignore_entity_patterns=ignore_files), + rust_timeout=0, + ) + ) def set_date_tzinfo(d, tz_name=None): @@ -942,39 +818,35 @@ def split_all(path): >>> split_all(os.path.join('a', 'b', 'c')) ['a', 'b', 'c'] """ - components = [] - path = path.lstrip('/') - while path: - head, tail = os.path.split(path) - if tail: - components.insert(0, tail) - elif head == path: - components.insert(0, head) - break - path = head - return components - - -def is_selected_for_writing(settings, path): - '''Check whether path is selected for writing - according to the WRITE_SELECTED list - - If WRITE_SELECTED is an empty list (default), - any path is selected for writing. - ''' - if settings['WRITE_SELECTED']: - return path in settings['WRITE_SELECTED'] + if isinstance(path, str): + components = [] + path = path.lstrip("/") + while path: + head, tail = os.path.split(path) + if tail: + components.insert(0, tail) + elif head == path: + components.insert(0, head) + break + path = head + return components + elif isinstance(path, pathlib.Path): + return path.parts + elif path is None: + return None else: - return True + raise TypeError( + f'"path" was {type(path)}, must be string, None, or pathlib.Path' + ) def path_to_file_url(path): - '''Convert file-system path to file:// URL''' + """Convert file-system path to file:// URL""" return urllib.parse.urljoin("file://", urllib.request.pathname2url(path)) def maybe_pluralize(count, singular, plural): - ''' + """ Returns a formatted string containing count and plural if count is not 1 Returns count and singular if count is 1 @@ -982,8 +854,24 @@ def maybe_pluralize(count, singular, plural): maybe_pluralize(1, 'Article', 'Articles') -> '1 Article' maybe_pluralize(2, 'Article', 'Articles') -> '2 Articles' - ''' + """ selection = plural if count == 1: selection = singular - return '{} {}'.format(count, selection) + return f"{count} {selection}" + + +@contextmanager +def temporary_locale(temp_locale=None, lc_category=locale.LC_ALL): + """ + Enable code to run in a context with a temporary locale + Resets the locale back when exiting context. + + Use tests.support.TestCaseWithCLocale if you want every unit test in a + class to use the C locale. + """ + orig_locale = locale.setlocale(lc_category) + if temp_locale: + locale.setlocale(lc_category, temp_locale) + yield + locale.setlocale(lc_category, orig_locale) diff --git a/pelican/writers.py b/pelican/writers.py index 73ee4b33..d405fc88 100644 --- a/pelican/writers.py +++ b/pelican/writers.py @@ -9,14 +9,17 @@ from markupsafe import Markup from pelican.paginator import Paginator from pelican.plugins import signals -from pelican.utils import (get_relative_path, is_selected_for_writing, - path_to_url, sanitised_join, set_date_tzinfo) +from pelican.utils import ( + get_relative_path, + path_to_url, + sanitised_join, + set_date_tzinfo, +) logger = logging.getLogger(__name__) class Writer: - def __init__(self, output_path, settings=None): self.output_path = output_path self.reminder = dict() @@ -25,25 +28,26 @@ class Writer: self._overridden_files = set() # See Content._link_replacer for details - if "RELATIVE_URLS" in self.settings and self.settings['RELATIVE_URLS']: + if "RELATIVE_URLS" in self.settings and self.settings["RELATIVE_URLS"]: self.urljoiner = posix_join else: self.urljoiner = lambda base, url: urljoin( - base if base.endswith('/') else base + '/', url) + base if base.endswith("/") else base + "/", str(url) + ) def _create_new_feed(self, feed_type, feed_title, context): - feed_class = Rss201rev2Feed if feed_type == 'rss' else Atom1Feed + feed_class = Rss201rev2Feed if feed_type == "rss" else Atom1Feed if feed_title: - feed_title = context['SITENAME'] + ' - ' + feed_title + feed_title = context["SITENAME"] + " - " + feed_title else: - feed_title = context['SITENAME'] - feed = feed_class( + feed_title = context["SITENAME"] + return feed_class( title=Markup(feed_title).striptags(), - link=(self.site_url + '/'), + link=(self.site_url + "/"), feed_url=self.feed_url, - description=context.get('SITESUBTITLE', ''), - subtitle=context.get('SITESUBTITLE', None)) - return feed + description=context.get("SITESUBTITLE", ""), + subtitle=context.get("SITESUBTITLE", None), + ) def _add_item_to_the_feed(self, feed, item): title = Markup(item.title).striptags() @@ -53,7 +57,7 @@ class Writer: # RSS feeds use a single tag called 'description' for both the full # content and the summary content = None - if self.settings.get('RSS_FEED_SUMMARY_ONLY'): + if self.settings.get("RSS_FEED_SUMMARY_ONLY"): description = item.summary else: description = item.get_content(self.site_url) @@ -71,10 +75,10 @@ class Writer: if description == content: description = None - categories = list() - if hasattr(item, 'category'): + categories = [] + if hasattr(item, "category"): categories.append(item.category) - if hasattr(item, 'tags'): + if hasattr(item, "tags"): categories.extend(item.tags) feed.add_item( @@ -83,13 +87,15 @@ class Writer: unique_id=get_tag_uri(link, item.date), description=description, content=content, - categories=categories if categories else None, - author_name=getattr(item, 'author', ''), - pubdate=set_date_tzinfo( - item.date, self.settings.get('TIMEZONE', None)), + categories=categories or None, + author_name=getattr(item, "author", ""), + pubdate=set_date_tzinfo(item.date, self.settings.get("TIMEZONE", None)), updateddate=set_date_tzinfo( - item.modified, self.settings.get('TIMEZONE', None) - ) if hasattr(item, 'modified') else None) + item.modified, self.settings.get("TIMEZONE", None) + ) + if hasattr(item, "modified") + else None, + ) def _open_w(self, filename, encoding, override=False): """Open a file to write some content to it. @@ -99,23 +105,29 @@ class Writer: """ if filename in self._overridden_files: if override: - raise RuntimeError('File %s is set to be overridden twice' - % filename) - else: - logger.info('Skipping %s', filename) - filename = os.devnull + raise RuntimeError("File %s is set to be overridden twice" % filename) + logger.info("Skipping %s", filename) + filename = os.devnull elif filename in self._written_files: if override: - logger.info('Overwriting %s', filename) + logger.info("Overwriting %s", filename) else: - raise RuntimeError('File %s is to be overwritten' % filename) + raise RuntimeError("File %s is to be overwritten" % filename) if override: self._overridden_files.add(filename) self._written_files.add(filename) - return open(filename, 'w', encoding=encoding) + return open(filename, "w", encoding=encoding) - def write_feed(self, elements, context, path=None, url=None, - feed_type='atom', override_output=False, feed_title=None): + def write_feed( + self, + elements, + context, + path=None, + url=None, + feed_type="atom", + override_output=False, + feed_title=None, + ): """Generate a feed with the list of articles provided Return the feed. If no path or output_path is specified, just @@ -132,22 +144,16 @@ class Writer: name should be skipped to keep that one) :param feed_title: the title of the feed.o """ - if not is_selected_for_writing(self.settings, path): - return + self.site_url = context.get("SITEURL", path_to_url(get_relative_path(path))) - self.site_url = context.get( - 'SITEURL', path_to_url(get_relative_path(path))) - - self.feed_domain = context.get('FEED_DOMAIN') - self.feed_url = self.urljoiner(self.feed_domain, url if url else path) + self.feed_domain = context.get("FEED_DOMAIN") + self.feed_url = self.urljoiner(self.feed_domain, url or path) feed = self._create_new_feed(feed_type, feed_title, context) - max_items = len(elements) - if self.settings['FEED_MAX_ITEMS']: - max_items = min(self.settings['FEED_MAX_ITEMS'], max_items) - for i in range(max_items): - self._add_item_to_the_feed(feed, elements[i]) + # FEED_MAX_ITEMS = None means [:None] to get every element + for element in elements[: self.settings["FEED_MAX_ITEMS"]]: + self._add_item_to_the_feed(feed, element) signals.feed_generated.send(context, feed=feed) if path: @@ -158,17 +164,25 @@ class Writer: except Exception: pass - with self._open_w(complete_path, 'utf-8', override_output) as fp: - feed.write(fp, 'utf-8') - logger.info('Writing %s', complete_path) + with self._open_w(complete_path, "utf-8", override_output) as fp: + feed.write(fp, "utf-8") + logger.info("Writing %s", complete_path) - signals.feed_written.send( - complete_path, context=context, feed=feed) + signals.feed_written.send(complete_path, context=context, feed=feed) return feed - def write_file(self, name, template, context, relative_urls=False, - paginated=None, template_name=None, override_output=False, - url=None, **kwargs): + def write_file( + self, + name, + template, + context, + relative_urls=False, + paginated=None, + template_name=None, + override_output=False, + url=None, + **kwargs, + ): """Render the template and write the file. :param name: name of the file to output @@ -185,10 +199,7 @@ class Writer: :param **kwargs: additional variables to pass to the templates """ - if name is False or \ - name == "" or \ - not is_selected_for_writing(self.settings, - os.path.join(self.output_path, name)): + if name is False or name == "": return elif not name: # other stuff, just return for now @@ -197,8 +208,8 @@ class Writer: def _write_file(template, localcontext, output_path, name, override): """Render the template write the file.""" # set localsiteurl for context so that Contents can adjust links - if localcontext['localsiteurl']: - context['localsiteurl'] = localcontext['localsiteurl'] + if localcontext["localsiteurl"]: + context["localsiteurl"] = localcontext["localsiteurl"] output = template.render(localcontext) path = sanitised_join(output_path, name) @@ -207,9 +218,9 @@ class Writer: except Exception: pass - with self._open_w(path, 'utf-8', override=override) as f: + with self._open_w(path, "utf-8", override=override) as f: f.write(output) - logger.info('Writing %s', path) + logger.info("Writing %s", path) # Send a signal to say we're writing a file with some specific # local context. @@ -217,54 +228,66 @@ class Writer: def _get_localcontext(context, name, kwargs, relative_urls): localcontext = context.copy() - localcontext['localsiteurl'] = localcontext.get( - 'localsiteurl', None) + localcontext["localsiteurl"] = localcontext.get("localsiteurl", None) if relative_urls: relative_url = path_to_url(get_relative_path(name)) - localcontext['SITEURL'] = relative_url - localcontext['localsiteurl'] = relative_url - localcontext['output_file'] = name + localcontext["SITEURL"] = relative_url + localcontext["localsiteurl"] = relative_url + localcontext["output_file"] = name localcontext.update(kwargs) return localcontext if paginated is None: - paginated = {key: val for key, val in kwargs.items() - if key in {'articles', 'dates'}} + paginated = { + key: val for key, val in kwargs.items() if key in {"articles", "dates"} + } # pagination - if paginated and template_name in self.settings['PAGINATED_TEMPLATES']: + if paginated and template_name in self.settings["PAGINATED_TEMPLATES"]: # pagination needed - per_page = self.settings['PAGINATED_TEMPLATES'][template_name] \ - or self.settings['DEFAULT_PAGINATION'] + per_page = ( + self.settings["PAGINATED_TEMPLATES"][template_name] + or self.settings["DEFAULT_PAGINATION"] + ) # init paginators - paginators = {key: Paginator(name, url, val, self.settings, - per_page) - for key, val in paginated.items()} + paginators = { + key: Paginator(name, url, val, self.settings, per_page) + for key, val in paginated.items() + } # generated pages, and write for page_num in range(list(paginators.values())[0].num_pages): paginated_kwargs = kwargs.copy() for key in paginators.keys(): paginator = paginators[key] - previous_page = paginator.page(page_num) \ - if page_num > 0 else None + previous_page = paginator.page(page_num) if page_num > 0 else None page = paginator.page(page_num + 1) - next_page = paginator.page(page_num + 2) \ - if page_num + 1 < paginator.num_pages else None + next_page = ( + paginator.page(page_num + 2) + if page_num + 1 < paginator.num_pages + else None + ) paginated_kwargs.update( - {'%s_paginator' % key: paginator, - '%s_page' % key: page, - '%s_previous_page' % key: previous_page, - '%s_next_page' % key: next_page}) + { + "%s_paginator" % key: paginator, + "%s_page" % key: page, + "%s_previous_page" % key: previous_page, + "%s_next_page" % key: next_page, + } + ) localcontext = _get_localcontext( - context, page.save_as, paginated_kwargs, relative_urls) - _write_file(template, localcontext, self.output_path, - page.save_as, override_output) + context, page.save_as, paginated_kwargs, relative_urls + ) + _write_file( + template, + localcontext, + self.output_path, + page.save_as, + override_output, + ) else: # no pagination - localcontext = _get_localcontext( - context, name, kwargs, relative_urls) - _write_file(template, localcontext, self.output_path, name, - override_output) + localcontext = _get_localcontext(context, name, kwargs, relative_urls) + _write_file(template, localcontext, self.output_path, name, override_output) diff --git a/pyproject.toml b/pyproject.toml index 826c1179..c8bbe985 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,24 @@ -[tool.poetry] +[project] name = "pelican" -version = "4.8.0" +authors = [{ name = "Justin Mayer", email = "authors@getpelican.com" }] description = "Static site generator supporting Markdown and reStructuredText" -authors = ["Justin Mayer "] -license = "AGPLv3" +version = "4.9.1" +license = { text = "AGPLv3" } readme = "README.rst" keywords = ["static site generator", "static sites", "ssg"] - -homepage = "https://getpelican.com" -repository = "https://github.com/getpelican/pelican" -documentation = "https://docs.getpelican.com" - classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Framework :: Pelican", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System", "Topic :: Internet :: WWW/HTTP :: Site Management", @@ -24,50 +27,33 @@ classifiers = [ "Topic :: Text Processing :: Markup :: HTML", "Topic :: Text Processing :: Markup :: reStructuredText", ] +requires-python = ">=3.8.1,<4.0" +dependencies = [ + "blinker>=1.7.0", + "docutils>=0.20.1", + "feedgenerator>=2.1.0", + "jinja2>=3.1.2", + "ordered-set>=4.1.0", + "pygments>=2.16.1", + "python-dateutil>=2.8.2", + "rich>=13.6.0", + "unidecode>=1.3.7", + "backports-zoneinfo>=0.2.1; python_version < \"3.9\"", + "watchfiles>=0.21.0", + "tzdata; sys_platform == 'win32'", +] -[tool.poetry.urls] -"Funding" = "https://donate.getpelican.com/" -"Tracker" = "https://github.com/getpelican/pelican/issues" +[project.optional-dependencies] +markdown = ["markdown>=3.1"] -[tool.poetry.dependencies] -python = ">=3.7,<4.0" -blinker = ">=1.4" -docutils = ">=0.16" -feedgenerator = ">=1.9" -jinja2 = ">=2.7" -pygments = ">=2.6" -python-dateutil = ">=2.8" -rich = ">=10.1" -unidecode = ">=1.1" -markdown = {version = ">=3.1", optional = true} -backports-zoneinfo = {version = "^0.2.1", python = "<3.9"} +[project.urls] +Homepage = "https://getpelican.com" +Funding = "https://donate.getpelican.com/" +"Issue Tracker" = "https://github.com/getpelican/pelican/issues" +Repository = "https://github.com/getpelican/pelican" +Documentation = "https://docs.getpelican.com" -[tool.poetry.dev-dependencies] -BeautifulSoup4 = "^4.9" -jinja2 = "~3.1.2" -lxml = "^4.3" -markdown = "~3.4.3" -typogrify = "^2.0" -sphinx = "^5.1" -furo = "2023.03.27" -livereload = "^2.6" -psutil = {version = "^5.7", optional = true} -pygments = "~2.15" -pytest = "^7.1" -pytest-cov = "^4.0" -pytest-sugar = "^0.9.5" -pytest-xdist = "^2.0" -tox = {version = "^3.13", optional = true} -flake8 = "^3.8" -flake8-import-order = "^0.18.1" -invoke = "^2.0" -isort = "^5.2" -black = {version = "^19.10b0", allow-prereleases = true} - -[tool.poetry.extras] -markdown = ["markdown"] - -[tool.poetry.scripts] +[project.scripts] pelican = "pelican.__main__:main" pelican-import = "pelican.tools.pelican_import:main" pelican-plugins = "pelican.plugins._utils:list_plugins" @@ -81,8 +67,46 @@ git-email = "52496925+botpub@users.noreply.github.com" changelog-file = "docs/changelog.rst" changelog-header = "###############" version-header = "=" -version-strings = ["setup.py"] -build-system = "setuptools" + +[tool.pdm] + +[tool.pdm.scripts] +docbuild = "invoke docbuild" +docserve = "invoke docserve" +lint = "invoke lint" +test = "invoke tests" + +[tool.pdm.dev-dependencies] +dev = [ + "BeautifulSoup4>=4.12.2", + "jinja2>=3.1.2", + "lxml>=4.9.3", + "markdown>=3.5.1", + "typogrify>=2.0.7", + "sphinx>=7.1.2", + "sphinxext-opengraph>=0.9.0", + "furo==2023.9.10", + "livereload>=2.6.3", + "psutil>=5.9.6", + "pygments>=2.16.1", + "pytest>=7.4.3", + "pytest-cov>=4.1.0", + "pytest-sugar>=0.9.7", + "pytest-xdist>=3.4.0", + "tox>=4.11.3", + "invoke>=2.2.0", + "ruff>=0.1.5", + "tomli>=2.0.1; python_version < \"3.11\"", +] + +[tool.pdm.build] +source-includes = [ + "CONTRIBUTING.rst", + "THANKS", + "docs/changelog.rst", + "samples/", +] [build-system] -requires = ["setuptools >= 40.6.0", "wheel"] +requires = ["pdm-backend"] +build-backend = "pdm.backend" diff --git a/requirements/developer.pip b/requirements/developer.pip index 5c2f5a69..58b2a1dc 100644 --- a/requirements/developer.pip +++ b/requirements/developer.pip @@ -1,3 +1,2 @@ -r test.pip -r docs.pip --r style.pip diff --git a/requirements/docs.pip b/requirements/docs.pip index dda53a56..7b0f37cc 100644 --- a/requirements/docs.pip +++ b/requirements/docs.pip @@ -1,3 +1,5 @@ -sphinx<6.0 -furo +sphinx +sphinxext-opengraph +furo==2023.9.10 livereload +tomli;python_version<"3.11" diff --git a/requirements/style.pip b/requirements/style.pip deleted file mode 100644 index f1c82ed0..00000000 --- a/requirements/style.pip +++ /dev/null @@ -1,2 +0,0 @@ -flake8==3.9.2 -flake8-import-order diff --git a/requirements/test.pip b/requirements/test.pip index a7d566f5..8eb1029f 100644 --- a/requirements/test.pip +++ b/requirements/test.pip @@ -5,7 +5,7 @@ pytest-cov pytest-xdist[psutil] # Optional Packages -Markdown==3.4.3 +Markdown==3.5.1 BeautifulSoup4 lxml typogrify diff --git a/samples/content/pages/hidden_page.rst b/samples/content/pages/hidden_page.rst index ab8704ed..b1f52d95 100644 --- a/samples/content/pages/hidden_page.rst +++ b/samples/content/pages/hidden_page.rst @@ -6,4 +6,3 @@ This is a test hidden page This is great for things like error(404) pages Anyone can see this page but it's not linked to anywhere! - diff --git a/samples/content/unbelievable.rst b/samples/content/unbelievable.rst index 209e3557..afa502cf 100644 --- a/samples/content/unbelievable.rst +++ b/samples/content/unbelievable.rst @@ -45,7 +45,7 @@ Testing more sourcecode directives :lineseparator:
        :linespans: foo :nobackground: - + def run(self): self.assert_has_content() try: @@ -76,8 +76,8 @@ Testing even more sourcecode directives .. sourcecode:: python :linenos: table :nowrap: - - + + formatter = self.options and VARIANTS[self.options.keys()[0]] @@ -90,8 +90,8 @@ Even if the default is line numbers, we can override it here .. sourcecode:: python :linenos: none - - + + formatter = self.options and VARIANTS[self.options.keys()[0]] diff --git a/samples/pelican.conf.py b/samples/pelican.conf.py index 1fa7c472..d10254e8 100755 --- a/samples/pelican.conf.py +++ b/samples/pelican.conf.py @@ -1,55 +1,59 @@ -AUTHOR = 'Alexis Métaireau' +AUTHOR = "Alexis Métaireau" SITENAME = "Alexis' log" -SITESUBTITLE = 'A personal blog.' -SITEURL = 'http://blog.notmyidea.org' +SITESUBTITLE = "A personal blog." +SITEURL = "http://blog.notmyidea.org" TIMEZONE = "Europe/Paris" # can be useful in development, but set to False when you're ready to publish RELATIVE_URLS = True -GITHUB_URL = 'http://github.com/ametaireau/' +GITHUB_URL = "http://github.com/ametaireau/" DISQUS_SITENAME = "blog-notmyidea" REVERSE_CATEGORY_ORDER = True LOCALE = "C" DEFAULT_PAGINATION = 4 DEFAULT_DATE = (2012, 3, 2, 14, 1, 1) -FEED_ALL_RSS = 'feeds/all.rss.xml' -CATEGORY_FEED_RSS = 'feeds/{slug}.rss.xml' +FEED_ALL_RSS = "feeds/all.rss.xml" +CATEGORY_FEED_RSS = "feeds/{slug}.rss.xml" -LINKS = (('Biologeek', 'http://biologeek.org'), - ('Filyb', "http://filyb.info/"), - ('Libert-fr', "http://www.libert-fr.com"), - ('N1k0', "http://prendreuncafe.com/blog/"), - ('Tarek Ziadé', "http://ziade.org/blog"), - ('Zubin Mithra', "http://zubin71.wordpress.com/"),) +LINKS = ( + ("Biologeek", "http://biologeek.org"), + ("Filyb", "http://filyb.info/"), + ("Libert-fr", "http://www.libert-fr.com"), + ("N1k0", "http://prendreuncafe.com/blog/"), + ("Tarek Ziadé", "http://ziade.org/blog"), + ("Zubin Mithra", "http://zubin71.wordpress.com/"), +) -SOCIAL = (('twitter', 'http://twitter.com/ametaireau'), - ('lastfm', 'http://lastfm.com/user/akounet'), - ('github', 'http://github.com/ametaireau'),) +SOCIAL = ( + ("twitter", "http://twitter.com/ametaireau"), + ("lastfm", "http://lastfm.com/user/akounet"), + ("github", "http://github.com/ametaireau"), +) # global metadata to all the contents -DEFAULT_METADATA = {'yeah': 'it is'} +DEFAULT_METADATA = {"yeah": "it is"} # path-specific metadata EXTRA_PATH_METADATA = { - 'extra/robots.txt': {'path': 'robots.txt'}, - } + "extra/robots.txt": {"path": "robots.txt"}, +} # static paths will be copied without parsing their contents STATIC_PATHS = [ - 'images', - 'extra/robots.txt', - ] + "images", + "extra/robots.txt", +] # custom page generated with a jinja2 template -TEMPLATE_PAGES = {'pages/jinja2_template.html': 'jinja2_template.html'} +TEMPLATE_PAGES = {"pages/jinja2_template.html": "jinja2_template.html"} # there is no other HTML content -READERS = {'html': None} +READERS = {"html": None} # code blocks with line numbers -PYGMENTS_RST_OPTIONS = {'linenos': 'table'} +PYGMENTS_RST_OPTIONS = {"linenos": "table"} # foobar will not be used, because it's not in caps. All configuration keys # have to be in caps diff --git a/samples/pelican.conf_FR.py b/samples/pelican.conf_FR.py index dc657404..cbca06df 100644 --- a/samples/pelican.conf_FR.py +++ b/samples/pelican.conf_FR.py @@ -1,56 +1,60 @@ -AUTHOR = 'Alexis Métaireau' +AUTHOR = "Alexis Métaireau" SITENAME = "Alexis' log" -SITEURL = 'http://blog.notmyidea.org' +SITEURL = "http://blog.notmyidea.org" TIMEZONE = "Europe/Paris" # can be useful in development, but set to False when you're ready to publish RELATIVE_URLS = True -GITHUB_URL = 'http://github.com/ametaireau/' +GITHUB_URL = "http://github.com/ametaireau/" DISQUS_SITENAME = "blog-notmyidea" PDF_GENERATOR = False REVERSE_CATEGORY_ORDER = True LOCALE = "fr_FR.UTF-8" DEFAULT_PAGINATION = 4 DEFAULT_DATE = (2012, 3, 2, 14, 1, 1) -DEFAULT_DATE_FORMAT = '%d %B %Y' +DEFAULT_DATE_FORMAT = "%d %B %Y" -ARTICLE_URL = 'posts/{date:%Y}/{date:%B}/{date:%d}/{slug}/' -ARTICLE_SAVE_AS = ARTICLE_URL + 'index.html' +ARTICLE_URL = "posts/{date:%Y}/{date:%B}/{date:%d}/{slug}/" +ARTICLE_SAVE_AS = ARTICLE_URL + "index.html" -FEED_ALL_RSS = 'feeds/all.rss.xml' -CATEGORY_FEED_RSS = 'feeds/{slug}.rss.xml' +FEED_ALL_RSS = "feeds/all.rss.xml" +CATEGORY_FEED_RSS = "feeds/{slug}.rss.xml" -LINKS = (('Biologeek', 'http://biologeek.org'), - ('Filyb', "http://filyb.info/"), - ('Libert-fr', "http://www.libert-fr.com"), - ('N1k0', "http://prendreuncafe.com/blog/"), - ('Tarek Ziadé', "http://ziade.org/blog"), - ('Zubin Mithra', "http://zubin71.wordpress.com/"),) +LINKS = ( + ("Biologeek", "http://biologeek.org"), + ("Filyb", "http://filyb.info/"), + ("Libert-fr", "http://www.libert-fr.com"), + ("N1k0", "http://prendreuncafe.com/blog/"), + ("Tarek Ziadé", "http://ziade.org/blog"), + ("Zubin Mithra", "http://zubin71.wordpress.com/"), +) -SOCIAL = (('twitter', 'http://twitter.com/ametaireau'), - ('lastfm', 'http://lastfm.com/user/akounet'), - ('github', 'http://github.com/ametaireau'),) +SOCIAL = ( + ("twitter", "http://twitter.com/ametaireau"), + ("lastfm", "http://lastfm.com/user/akounet"), + ("github", "http://github.com/ametaireau"), +) # global metadata to all the contents -DEFAULT_METADATA = {'yeah': 'it is'} +DEFAULT_METADATA = {"yeah": "it is"} # path-specific metadata EXTRA_PATH_METADATA = { - 'extra/robots.txt': {'path': 'robots.txt'}, - } + "extra/robots.txt": {"path": "robots.txt"}, +} # static paths will be copied without parsing their contents STATIC_PATHS = [ - 'pictures', - 'extra/robots.txt', - ] + "pictures", + "extra/robots.txt", +] # custom page generated with a jinja2 template -TEMPLATE_PAGES = {'pages/jinja2_template.html': 'jinja2_template.html'} +TEMPLATE_PAGES = {"pages/jinja2_template.html": "jinja2_template.html"} # code blocks with line numbers -PYGMENTS_RST_OPTIONS = {'linenos': 'table'} +PYGMENTS_RST_OPTIONS = {"linenos": "table"} # foobar will not be used, because it's not in caps. All configuration keys # have to be in caps diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2a9acf13..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100755 index 18eedb00..00000000 --- a/setup.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python - -from os import walk -from os.path import join, relpath - -from setuptools import find_packages, setup - - -version = "4.8.0" - -requires = ['feedgenerator >= 1.9', 'jinja2 >= 2.7', 'pygments', - 'docutils>=0.15', 'blinker', 'unidecode', 'python-dateutil', - 'rich', 'backports-zoneinfo[tzdata] >= 0.2; python_version<"3.9"'] - -entry_points = { - 'console_scripts': [ - 'pelican = pelican.__main__:main', - 'pelican-import = pelican.tools.pelican_import:main', - 'pelican-quickstart = pelican.tools.pelican_quickstart:main', - 'pelican-themes = pelican.tools.pelican_themes:main', - 'pelican-plugins = pelican.plugins._utils:list_plugins' - ] -} - -README = open('README.rst', encoding='utf-8').read() -CHANGELOG = open('docs/changelog.rst', encoding='utf-8').read() - -# Relative links in the README must be converted to absolute URL's -# so that they render correctly on PyPI. -README = README.replace( - "", - "", -) - -description = '\n'.join([README, CHANGELOG]) - -setup( - name='pelican', - version=version, - url='https://getpelican.com/', - author='Justin Mayer', - author_email='authors@getpelican.com', - description="Static site generator supporting reStructuredText and " - "Markdown source content.", - project_urls={ - 'Documentation': 'https://docs.getpelican.com/', - 'Funding': 'https://donate.getpelican.com/', - 'Source': 'https://github.com/getpelican/pelican', - 'Tracker': 'https://github.com/getpelican/pelican/issues', - }, - keywords='static web site generator SSG reStructuredText Markdown', - license='AGPLv3', - long_description=description, - long_description_content_type='text/x-rst', - packages=find_packages(), - include_package_data=True, # includes all in MANIFEST.in if in package - # NOTE : This will collect any files that happen to be in the themes - # directory, even though they may not be checked into version control. - package_data={ # pelican/themes is not a package, so include manually - 'pelican': [relpath(join(root, name), 'pelican') - for root, _, names in walk(join('pelican', 'themes')) - for name in names], - }, - install_requires=requires, - extras_require={ - 'Markdown': ['markdown~=3.1.1'] - }, - entry_points=entry_points, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Framework :: Pelican', - 'License :: OSI Approved :: GNU Affero General Public License v3', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: Implementation :: CPython', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - test_suite='pelican.tests', -) diff --git a/tasks.py b/tasks.py index 148899c7..64409e20 100644 --- a/tasks.py +++ b/tasks.py @@ -8,16 +8,16 @@ PKG_NAME = "pelican" PKG_PATH = Path(PKG_NAME) DOCS_PORT = os.environ.get("DOCS_PORT", 8000) BIN_DIR = "bin" if os.name != "nt" else "Scripts" -PTY = True if os.name != "nt" else False +PTY = os.name != "nt" ACTIVE_VENV = os.environ.get("VIRTUAL_ENV", None) VENV_HOME = Path(os.environ.get("WORKON_HOME", "~/virtualenvs")) VENV_PATH = Path(ACTIVE_VENV) if ACTIVE_VENV else (VENV_HOME / PKG_NAME) VENV = str(VENV_PATH.expanduser()) VENV_BIN = Path(VENV) / Path(BIN_DIR) -TOOLS = ["poetry", "pre-commit", "psutil"] -POETRY = which("poetry") if which("poetry") else (VENV_BIN / "poetry") -PRECOMMIT = which("pre-commit") if which("pre-commit") else (VENV_BIN / "pre-commit") +TOOLS = ["pdm", "pre-commit", "psutil"] +PDM = which("pdm") or VENV_BIN / "pdm" +PRECOMMIT = which("pre-commit") or VENV_BIN / "pre-commit" @task @@ -45,34 +45,41 @@ def tests(c): @task -def black(c, check=False, diff=False): - """Run Black auto-formatter, optionally with --check or --diff""" +def coverage(c): + """Generate code coverage of running the test suite.""" + c.run(f"{VENV_BIN}/pytest --cov=pelican", pty=PTY) + c.run(f"{VENV_BIN}/coverage html", pty=PTY) + + +@task +def format(c, check=False, diff=False): + """Run Ruff's auto-formatter, optionally with --check or --diff""" check_flag, diff_flag = "", "" if check: check_flag = "--check" if diff: diff_flag = "--diff" - c.run(f"{VENV_BIN}/black {check_flag} {diff_flag} {PKG_PATH} tasks.py", pty=PTY) + c.run( + f"{VENV_BIN}/ruff format {check_flag} {diff_flag} {PKG_PATH} tasks.py", pty=PTY + ) @task -def isort(c, check=False, diff=False): - check_flag, diff_flag = "", "" - if check: - check_flag = "-c" +def ruff(c, fix=False, diff=False): + """Run Ruff to ensure code meets project standards.""" + diff_flag, fix_flag = "", "" + if fix: + fix_flag = "--fix" if diff: diff_flag = "--diff" - c.run(f"{VENV_BIN}/isort {check_flag} {diff_flag} .", pty=PTY) + c.run(f"{VENV_BIN}/ruff check {diff_flag} {fix_flag} .", pty=PTY) @task -def flake8(c): - c.run(f"git diff HEAD | {VENV_BIN}/flake8 --diff --max-line-length=88", pty=PTY) - - -@task -def lint(c): - flake8(c) +def lint(c, fix=False, diff=False): + """Check code style via linting tools.""" + ruff(c, fix=fix, diff=diff) + format(c, check=not fix, diff=diff) @task @@ -93,7 +100,7 @@ def precommit(c): def setup(c): c.run(f"{VENV_BIN}/python -m pip install -U pip", pty=PTY) tools(c) - c.run(f"{POETRY} install", pty=PTY) + c.run(f"{PDM} install", pty=PTY) precommit(c) diff --git a/tox.ini b/tox.ini index 8f43fbc5..f6f45af1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,8 @@ [tox] -envlist = py{3.7,3.8,3.9,3.10,3.11.3.12},docs,flake8 +envlist = py{3.8,3.9,3.10,3.11.3.12},docs [testenv] basepython = - py3.7: python3.7 py3.8: python3.8 py3.9: python3.9 py3.10: python3.10 @@ -31,17 +30,3 @@ filterwarnings = default::DeprecationWarning error:.*:Warning:pelican addopts = -n auto -r a - -[flake8] -application-import-names = pelican -import-order-style = cryptography -max-line-length = 88 - -[testenv:flake8] -basepython = python3.9 -skip_install = true -deps = - -rrequirements/style.pip -commands = - flake8 --version - flake8 pelican