diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 414c491..0000000 --- a/.flake8 +++ /dev/null @@ -1,7 +0,0 @@ -[flake8] -max-line-length = 88 -extend-ignore = - E203 # "Whitespace before ':'" - not PEP-8 compliant - E501 # "Line too long (82 >= 79 characters)" -per-file-ignores = - __init__.py:F401 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml new file mode 100644 index 0000000..5c11aa1 --- /dev/null +++ b/.github/workflows/unit.yml @@ -0,0 +1,48 @@ +name: Unit tests + +on: + - push + - pull_request + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + - uses: pre-commit/action@v3.0.0 + + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install .[test,dev] + + - name: Check static typing + run: mypy sphinxcontrib + + - name: test with pytest + run: pytest --color=yes --cov --cov-report=xml tests + + #- name: coverage + # run: coverage xml + # + #- name: codecov + # uses: codecov/codecov-action@v3 + # with: + # token: ${{ secrets.CODECOV_TOKEN }} + # verbose: true diff --git a/.gitignore b/.gitignore index 042c8f7..c394f6f 100644 --- a/.gitignore +++ b/.gitignore @@ -88,13 +88,6 @@ ipython_config.py # pyenv .python-version -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ @@ -135,4 +128,7 @@ dmypy.json # pytype static type analyzer .pytype/ +# ruff +.ruff_cache + # End of https://www.toptal.com/developers/gitignore/api/python diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 410e266..dd900e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,67 +1,21 @@ +default_install_hook_types: [pre-commit] + repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + - repo: "https://github.com/psf/black" + rev: "22.3.0" hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: trailing-whitespace - args: [--markdown-linebreak-ext=md] - - id: end-of-file-fixer - - id: check-json - - id: check-toml - - id: check-yaml - - id: requirements-txt-fixer - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 + - id: black + stages: [commit] + + - repo: "https://github.com/pre-commit/mirrors-prettier" + rev: "v2.7.1" hooks: - id: prettier - - repo: https://github.com/asottile/setup-cfg-fmt - rev: v2.2.0 - hooks: - - id: setup-cfg-fmt - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 - hooks: - - id: pyupgrade - args: [--py37-plus] - ## <<< darker ## - ## - repo: https://github.com/akaihola/darker - ## rev: 1.4.0 - ## hooks: - ## - id: darker - ## args: ["--isort"] # TODO: Move to pyproject.toml - ## additional_dependencies: - ## - isort - ## === ## - - repo: https://github.com/PyCQA/isort - rev: 5.11.4 - hooks: - - id: isort - - repo: https://github.com/psf/black - rev: 22.12.0 - hooks: - - id: black # black-jupyter - ## >>> black and isort ## - - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 - hooks: - - id: bandit - args: [--recursive, --quiet] - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 # E***, W***, F*** - additional_dependencies: - # - dlint # DUO*** - - flake8-2020 # YTT*** - - flake8-bugbear # B*** - - flake8-builtins # A*** - - flake8-comprehensions # C4** - - flake8-deprecated # D*** - - flake8-variables-names # VNE*** - - mccabe # C9** - - pep8-naming # N8** - # - repo: https://github.com/pre-commit/mirrors-mypy - # rev: v0.790 - # hooks: - # - id: mypy + stages: [commit] + exclude: tests\/test_build\/.+\.html$ + + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: "v0.0.215" + hooks: + - id: ruff + stages: [commit] diff --git a/.prettierrc.yaml b/.prettierrc.yaml deleted file mode 100644 index ca09957..0000000 --- a/.prettierrc.yaml +++ /dev/null @@ -1 +0,0 @@ -printWidth: 88 diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..77547bf --- /dev/null +++ b/noxfile.py @@ -0,0 +1,29 @@ +"""All the process that can be run using nox. + +The nox run are build in isolated environment that will be stored in .nox. to force the venv update, remove the .nox/xxx folder. +""" + +import nox + + +@nox.session(reuse_venv=True) +def lint(session): + """Apply the pre-commits.""" + session.install("pre-commit") + session.run("pre-commit", "run", "--a", *session.posargs) + + +@nox.session(reuse_venv=True) +def mypy(session): + """Run a mypy check of the lib.""" + session.install(".[dev]") + test_files = session.posargs or ["sphinxcontrib"] + session.run("mypy", *test_files) + + +@nox.session(reuse_venv=True) +def test(session): + """Run all the test using the environment varialbe of the running machine.""" + session.install(".[test]") + test_files = session.posargs or ["tests"] + session.run("pytest", "--color=yes", "--cov", "--cov-report=html", *test_files) diff --git a/pyproject.toml b/pyproject.toml index 551e212..9bec45c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,74 @@ [build-system] -requires = [ - "setuptools>=45", - "setuptools_scm[toml]>=6.2", - "wheel", -] +requires = ["setuptools>=61.2", "wheel"] build-backend = "setuptools.build_meta" -[tool.setuptools_scm] +[project] +name = "sphinxcontrib-email" +version = "0.3.6" +description = "Sphinx email obfuscation extension" +requires-python = ">=3.7" +dependencies = ["sphinx", "lxml>=4.5.2", "importlib_metadata"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Environment :: Web Environment", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "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", + "Topic :: Documentation :: Sphinx", + "Topic :: Utilities", +] + +[[project.authors]] +name = "Christian Knittl-Frank" +email = "lcnittl@gmail.com" + +[project.license] +text = "BSD-3-Clause" + +[project.readme] +file = "README.rst" +content-type = "text/x-rst" + +[project.urls] +repository = "https://github.com/sphinx-contrib/email" + +[project.optional-dependencies] +dev = ["nox", "pre-commit", "mypy"] +test = ["pytest", "beautifulsoup4", "pytest-regressions", "pytest-cov"] + +[tool.setuptools] +include-package-data = false +license-files = ["LICENSE"] +packages = ["sphinxcontrib"] + +[tool.ruff] +ignore-init-module-imports = true +fix = true +select = ["E", "F", "W", "I", "D", "RUF"] +ignore = ["E501"] # line too long | Black take care of it + +[tool.ruff.flake8-quotes] +docstring-quotes = "double" + +[tool.ruff.pydocstyle] +convention = "google" + +[tool.ruff.per-file-ignores] +"setup.py" = ["D100"] # nothing to see there -[tool.black] -target-version = ["py38", "py39", "py310"] +[tool.coverage.run] +source = ["sphinxcontrib.email"] -[tool.isort] -profile = "black" +[tool.mypy] +scripts_are_modules = true +ignore_missing_imports = true +install_types = true +non_interactive = true +warn_redundant_casts = true diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 416634f..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ -pre-commit diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 69a3121..0000000 --- a/setup.cfg +++ /dev/null @@ -1,45 +0,0 @@ -[metadata] -name = sphinxcontrib_email -description = Sphinx email obfuscation extension -long_description = file: README.rst -long_description_content_type = text/x-rst -url = https://github.com/sphinx-contrib/email -author = Christian Knittl-Frank -author_email = lcnittl@gmail.com -license = BSD-3-Clause -license_file = LICENSE -license_files = LICENSE -classifiers = - Development Status :: 3 - Alpha - Environment :: Console - Environment :: Web Environment - Framework :: Sphinx :: Extension - Intended Audience :: Developers - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Topic :: Documentation :: Sphinx - Topic :: Utilities -download_url = http://pypi.python.org/pypi/sphinxcontrib-email -project_urls = - GitHub: repo = https://github.com/sphinx-contrib/email - GitHub: issues = https://github.com/sphinx-contrib/email/issues - -[options] -packages = find_namespace: -install_requires = - Sphinx>=1.8 - lxml>=4.5.2 -python_requires = >=3.7 -include_package_data = True -package_dir = - = src -platforms = any -zip_safe = False - -[options.packages.find] -where = src - -[aliases] -release = check -rs sdist bdist_wheel diff --git a/setup.py b/setup.py index 4fdfbbb..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,3 @@ -import setuptools +from setuptools import setup -if __name__ == "__main__": - setuptools.setup( - use_scm_version=True, - setup_requires="setuptools-scm>=4.1", - ) +setup() diff --git a/src/sphinxcontrib/email/__init__.py b/sphinxcontrib/email/__init__.py similarity index 53% rename from src/sphinxcontrib/email/__init__.py rename to sphinxcontrib/email/__init__.py index 4166ad8..f2cd2c3 100644 --- a/src/sphinxcontrib/email/__init__.py +++ b/sphinxcontrib/email/__init__.py @@ -1,31 +1,27 @@ +"""Provide an email obfuscator for Sphinx-based documentation.""" + from typing import Any, Dict +from importlib_metadata import version from sphinx.application import Sphinx from sphinx.util import logging from .handlers import html_page_context_handler -from .roles import EmailRole - -try: - from importlib import metadata -except ImportError: - import importlib_metadata as metadata +from .roles import Email -try: - __version__ = metadata.version("sphinxcontrib-email") -except metadata.PackageNotFoundError: - pass +__version__ = version("sphinxcontrib-email") logger = logging.getLogger("sphinxcontrib-email") def setup(app: Sphinx) -> Dict[str, Any]: + """Setup email role parameters.""" app.add_config_value(name="email_automode", default=False, rebuild="env") app.connect(event="html-page-context", callback=html_page_context_handler) - app.add_role(name="email", role=EmailRole()) + app.add_role(name="email", role=Email()) - metadata = { - "version": ".".join(__version__.split(".")[:3]), + return { + "version": __version__, "parallel_read_safe": True, + "parallel_write_safe": True, } - return metadata diff --git a/src/sphinxcontrib/email/handlers.py b/sphinxcontrib/email/handlers.py similarity index 85% rename from src/sphinxcontrib/email/handlers.py rename to sphinxcontrib/email/handlers.py index 9b39a25..1a408fe 100644 --- a/src/sphinxcontrib/email/handlers.py +++ b/sphinxcontrib/email/handlers.py @@ -1,6 +1,8 @@ +"""Custom handlers for the email role.""" + from typing import Dict -import lxml.html # nosec # noqa DUO107 +import lxml.html from sphinx.application import Sphinx from sphinx.util import logging @@ -12,10 +14,8 @@ def html_page_context_handler( app: Sphinx, pagename: str, templatename: str, context: Dict, doctree: bool ): - """Search html for 'mailto' links and obfuscate them""" - if not app.config["email_automode"]: - return - if not doctree: + """Search html for 'mailto' links and obfuscate them.""" + if not app.config["email_automode"] or not doctree: return tree = lxml.html.fragment_fromstring(context["body"], create_parent="body") diff --git a/sphinxcontrib/email/roles.py b/sphinxcontrib/email/roles.py new file mode 100644 index 0000000..3a5bec9 --- /dev/null +++ b/sphinxcontrib/email/roles.py @@ -0,0 +1,36 @@ +"""Email role.""" + +import re +from typing import List, Tuple + +from docutils import nodes +from docutils.nodes import Node +from sphinx.util import logging +from sphinx.util.docutils import SphinxRole + +from .utils import Obfuscator + +logger = logging.getLogger(f"sphinxcontrib-email.{__name__}") + +PATTERN = r"^(?:(?P.*?)\s*<)?(?P\b[-.\w]+@[-.\w]+\.[a-z]{2,4}\b)>?$" +"email adress pattern" + + +class Email(SphinxRole): + """Role to obfuscate e-mail addresses. + + Handle addresses of the form "name@domain.org" and "Name Surname " + """ + + def run(self) -> Tuple[List[Node], List[str]]: + """Setup the role in the builder context.""" + match = re.search(PATTERN, self.text) + if not match: + return [], [] + data = match.groupdict() + + obfuscated = Obfuscator().js_obfuscated_mailto( + email=data["email"], displayname=data["name"] + ) + node = nodes.raw("", obfuscated, format="html") # type: ignore + return [node], [] diff --git a/src/sphinxcontrib/email/utils.py b/sphinxcontrib/email/utils.py similarity index 69% rename from src/sphinxcontrib/email/utils.py rename to sphinxcontrib/email/utils.py index ac14589..c313c63 100644 --- a/src/sphinxcontrib/email/utils.py +++ b/sphinxcontrib/email/utils.py @@ -1,7 +1,9 @@ +"""Set of helpers to build the email role.""" + import re import textwrap -import xml.sax.saxutils # nosec -from xml.etree import ElementTree as ET # nosec # noqa DUO107 +import xml.sax.saxutils +from xml.etree import ElementTree as ET from sphinx.util import logging @@ -9,7 +11,10 @@ class Obfuscator: + """Obfuscator for html output.""" + def __init__(self): + """Obfuscator for html output.""" self.rot_13_trans = str.maketrans( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm", @@ -18,7 +23,6 @@ def __init__(self): def rot_13_encrypt(self, line: str) -> str: """Rotate 13 encryption.""" line = line.translate(self.rot_13_trans) - line = re.sub(r"(?=[\"])", r"\\", line) line = re.sub("\n", r"\n", line) line = re.sub(r"@", r"\\100", line) line = re.sub(r"\.", r"\\056", line) @@ -26,7 +30,7 @@ def rot_13_encrypt(self, line: str) -> str: return line def xml_to_unesc_string(self, node: ET.Element) -> str: - """Return unescaped xml string""" + """Return unescaped xml string.""" text = xml.sax.saxutils.unescape( ET.tostring(node, encoding="unicode", method="html"), {"'": "'", """: '"'}, @@ -34,29 +38,30 @@ def xml_to_unesc_string(self, node: ET.Element) -> str: return text def js_obfuscated_text(self, text: str) -> str: - """ROT 13 encryption with embedded in Javascript code to decrypt in the - browser. - """ + """ROT 13 encryption embedded in Javascript code to decrypt in the browser.""" xml_node = ET.Element("script") xml_node.attrib["type"] = "text/javascript" + xml_node.attrib["class"] = "obfuscated-email" js_script = textwrap.dedent( - """\ + """ document.write( - "{text}".replace(/[a-zA-Z]/g, - function(c){{ - return String.fromCharCode( - (c<="Z"?90:122)>=(c=c.charCodeAt(0)+13)?c:c-26 - ); - }} - ) - );""" + '{text}'.replace( + /[a-zA-Z]/g, + function (c) {{ + return String.fromCharCode( + (c <= "Z" ? 90 : 122) >= (c = c.charCodeAt(0) + 13) ? c : c - 26 + ); + }} + ) + ); + """ ) xml_node.text = js_script.format(text=self.rot_13_encrypt(text)) return self.xml_to_unesc_string(xml_node) - def js_obfuscated_mailto(self, email: str, displayname: str = None) -> str: - """ROT 13 encryption within an Anchor tag w/ a mailto: attribute""" + def js_obfuscated_mailto(self, email: str, displayname: str = "") -> str: + """ROT 13 encryption within an Anchor tag w/ a mailto: attribute.""" xml_node = ET.Element("a") xml_node.attrib["class"] = "reference external" xml_node.attrib["href"] = f"mailto:{email}" diff --git a/src/sphinxcontrib/email/roles.py b/src/sphinxcontrib/email/roles.py deleted file mode 100644 index d77bdb4..0000000 --- a/src/sphinxcontrib/email/roles.py +++ /dev/null @@ -1,34 +0,0 @@ -import re -from typing import List, Tuple - -from docutils import nodes -from docutils.nodes import Node, system_message -from sphinx.util import logging -from sphinx.util.docutils import SphinxRole - -from .utils import Obfuscator - -logger = logging.getLogger(f"sphinxcontrib-email.{__name__}") - - -class EmailRole(SphinxRole): - def run(self) -> Tuple[List[Node], List[system_message]]: - """Role to obfuscate e-mail addresses. - - Handle addresses of the form - "name@domain.org" - "Name Surname " - """ - pattern = ( - r"^(?:(?P.*?)\s*<)?(?P\b[-.\w]+@[-.\w]+\.[a-z]{2,4}\b)>?$" - ) - match = re.search(pattern, self.text) - if not match: - return [], [] - data = match.groupdict() - - obfuscated = Obfuscator().js_obfuscated_mailto( - email=data["email"], displayname=data["name"] - ) - node = nodes.raw("", obfuscated, format="html") - return [node], [] diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..e743ccf --- /dev/null +++ b/test.txt @@ -0,0 +1,12 @@ + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..190373f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Make the test folder a package for coverage reports.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..db50b26 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Pytest configuration.""" + +import pytest +from sphinx.testing.path import path + +pytest_plugins = "sphinx.testing.fixtures" + + +@pytest.fixture(scope="session") +def rootdir(): + """Get the root directory for the whole test session.""" + return path(__file__).parent.abspath() / "roots" diff --git a/tests/roots/test-email/conf.py b/tests/roots/test-email/conf.py new file mode 100644 index 0000000..b36a051 --- /dev/null +++ b/tests/roots/test-email/conf.py @@ -0,0 +1,5 @@ +"""Configuration file for the Sphinx documentation builder.""" + +extensions = ["sphinxcontrib.email"] +exclude_patterns = ["_build"] +html_theme = "basic" diff --git a/tests/roots/test-email/index.rst b/tests/roots/test-email/index.rst new file mode 100644 index 0000000..0ce2b23 --- /dev/null +++ b/tests/roots/test-email/index.rst @@ -0,0 +1,7 @@ +email +===== + +.. toctree:: + + simple_email + named_email \ No newline at end of file diff --git a/tests/roots/test-email/named_email.rst b/tests/roots/test-email/named_email.rst new file mode 100644 index 0000000..e44db41 --- /dev/null +++ b/tests/roots/test-email/named_email.rst @@ -0,0 +1,4 @@ +email +===== + +send an email to :email:`toto `. \ No newline at end of file diff --git a/tests/roots/test-email/simple_email.rst b/tests/roots/test-email/simple_email.rst new file mode 100644 index 0000000..cae56d6 --- /dev/null +++ b/tests/roots/test-email/simple_email.rst @@ -0,0 +1,4 @@ +email +===== + +send an email to :email:`toto@gmail.com`. \ No newline at end of file diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..e0c00b7 --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,31 @@ +"""Test sphinxcontrib.video extension.""" + +from pathlib import Path + +import pytest +from bs4 import BeautifulSoup, formatter + +fmt = formatter.HTMLFormatter(indent=2, void_element_close_prefix=" /") + + +@pytest.mark.sphinx(testroot="email") +def test_email(app, status, warning, file_regression): + """Build a video without options.""" + app.builder.build_all() + + raw_html = (app.outdir / "simple_email.html").read_text(encoding="utf8") + html = BeautifulSoup(raw_html, "html.parser") + script = html.select("script.obfuscated-email")[0].prettify(formatter=fmt) + Path("test.txt").write_text(script) + file_regression.check(script, basename="email", extension=".html") + + +@pytest.mark.sphinx(testroot="email") +def test_named_email(app, status, warning, file_regression): + """Build a video without options.""" + app.builder.build_all() + + raw_html = (app.outdir / "named_email.html").read_text(encoding="utf8") + html = BeautifulSoup(raw_html, "html.parser") + script = html.select("script.obfuscated-email")[0].prettify(formatter=fmt) + file_regression.check(script, basename="named-email", extension=".html") diff --git a/tests/test_build/email.html b/tests/test_build/email.html new file mode 100644 index 0000000..e743ccf --- /dev/null +++ b/tests/test_build/email.html @@ -0,0 +1,12 @@ + diff --git a/tests/test_build/named-email.html b/tests/test_build/named-email.html new file mode 100644 index 0000000..9031775 --- /dev/null +++ b/tests/test_build/named-email.html @@ -0,0 +1,12 @@ +