diff --git a/Pipfile b/Pipfile index 5c10bcc..bae3a1c 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -byteb4rb1e_sphinxcontrib = { editable = true, path = '.'} +byteb4rb1e.sphinxcontrib = { editable = true, path = '.'} [dev-packages] sphinx = "*" diff --git a/Pipfile.lock b/Pipfile.lock index ac11403..c4c3851 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "48c822d9e7bedd5ee25106b31093ce10a0071d7b12f7861e4e4cc46c5549c9ed" + "sha256": "4eac08fb6ab8c543a1fa6030c3c69a57aa6ae6de69e4f47ec483128bb61b2867" }, "pipfile-spec": 6, "requires": { @@ -32,9 +32,9 @@ "markers": "python_version >= '3.8'", "version": "==2.17.0" }, - "byteb4rb1e-sphinxcontrib": { - "editable": true, - "path": "." + "byteb4rb1e.utils": { + "git": "https://bitbucket.org/byteb4rb1e/py-utils.git", + "ref": "d0dfa1cb12702e6d25f3a9eeab02968eda8d06ba" }, "certifi": { "hashes": [ diff --git a/pyproject.toml b/pyproject.toml index ffd1365..28e550e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires = [ build-backend = "setuptools.build_meta" [project] -name = "byteb4rb1e.sphinxcontrib" +name = "byteb4rb1e.sphinxcontrib.ext" description = "" authors = [ { name = "Tiara Rodney", email = "tiara.rodney@byteb4rb1e.me" } @@ -30,6 +30,7 @@ classifiers = [ ] dependencies = [ "sphinx>=5.1", + "byteb4rb1e.utils @ git+https://bitbucket.org/byteb4rb1e/py-utils.git@32ae99c5fa0174761f4053fce1130f3cd7a2a68b" ] dynamic = ["version"] requires-python = ">=3.8" @@ -39,6 +40,7 @@ Bitbucket = "https://bitbucket.org/byteb4rb1e/sphinxcontrib" [tool.setuptools.packages.find] where = ["src"] +namespaces = true [tool.autopep8] diff --git a/src/byteb4rb1e/sphinxcontrib/ext/html_static_archive/__init__.py b/src/byteb4rb1e/sphinxcontrib/ext/html_static_archive/__init__.py new file mode 100644 index 0000000..b722588 --- /dev/null +++ b/src/byteb4rb1e/sphinxcontrib/ext/html_static_archive/__init__.py @@ -0,0 +1,85 @@ +import importlib.resources +import mimetypes +from pathlib import Path +import urllib.request +import tarfile +from typing import Tuple, Optional, Dict +from io import IOBase + +from sphinx.application import Sphinx +from sphinx.environment import BuildEnvironment +from sphinx.util.logging import getLogger + +from byteb4rb1e.utils.urllib.request import PkgHandler + + +logger = getLogger(__name__) + + +_static_archives: Dict[str, IOBase] = {} +_opener: urllib.request.OpenerDirector = urllib.request.build_opener( + PkgHandler() +) + + +def on_config_inited( + app: Sphinx, + env: BuildEnvironment +): + """ + """ + global _static_archives + + static_archives = app.config['html_static_archive'] + + if not static_archives: + return + + if isinstance(static_archives, str): + static_archives = [static_archives] + + for url in static_archives: + _static_archives[url] = _opener.open(url) + + +def on_env_updated( + app: Sphinx, + env: BuildEnvironment +): + """ + """ + for url, fh in _static_archives.items(): + modes = { + 'application/x-tar': 'r', + 'application/x-tar+gzip': 'r:gz', + 'application/x-tar+bzip2': 'r:bz2', + 'application/x-tar+xz': 'r:xz', + } + + mime_type = fh.headers.get('Content-Type') + + if mime_type not in modes.keys(): + raise Exception('') + + archive = tarfile.open(fileobj=fh, mode=modes[mime_type]) + + for file in archive.getmembers(): + if file.isdir(): + continue + + shadow_file = Path(app.outdir) / '_static' / file.name + + if not shadow_file.exists(): + logger.info(f'html_static_archive (extract): {url}::{file.name}') + shadow_file.parent.mkdir(parents=True) + archive.extract(file, path=shadow_file) + + archive.close() + fh.close() + + +def setup(app: Sphinx): + app.add_config_value('html_static_archive', [], True) + app.connect('env-check-consistency', on_config_inited) + app.connect('env-check-consistency', on_env_updated) + diff --git a/src/byteb4rb1e/sphinxcontrib/testing/pytest/fixtures.py b/src/byteb4rb1e/sphinxcontrib/testing/pytest/fixtures.py new file mode 100644 index 0000000..6fc4416 --- /dev/null +++ b/src/byteb4rb1e/sphinxcontrib/testing/pytest/fixtures.py @@ -0,0 +1,92 @@ +from collections import namedtuple +import json +import logging +import os +from pathlib import Path +import shutil +from typing import Any, Tuple, Callable, Iterator, Dict + +import pytest +from sphinx.testing.util import SphinxTestApp +from sphinx.testing.fixtures import make_app, test_params + +from byteb4rb1e.testing.pytest.fixtures import current_test + +_SPHINX_TESTAPP_COUNTER: Dict[str, int] = {} +_SPHINX_TESTSRC_COUNTER: Dict[str, int] = {} + + +@pytest.fixture +def mock_sphinx_testsrc_dir( + tmp_path: Path, +): + global _SPHINX_TESTSRC_COUNTER + + _SPHINX_TESTSRC_COUNTER.setdefault(tmp_path, 0) + + testsrc_id = _SPHINX_TESTSRC_COUNTER[tmp_path] + _SPHINX_TESTSRC_COUNTER[tmp_path] += 1 + + srcdir = tmp_path / f'sphinx-testsrc-{testsrc_id}' + + def wrap(): + srcdir.mkdir(parents=True) + + (srcdir / 'conf.py').write_text(""" +project = 'foobar' +master_doc = 'index' +""") + + (srcdir / 'index.rst').write_text(""" +###### +Foobar +###### + +Hello world! +""") + + return srcdir + + return wrap + + +@pytest.fixture +def mock_sphinx_testapp( + make_app: Callable[[], SphinxTestApp], + tmp_path: Path, + caplog +): + global _SPHINX_TESTAPP_COUNTER + + _SPHINX_TESTAPP_COUNTER.setdefault(tmp_path, 0) + + testapp_id = _SPHINX_TESTAPP_COUNTER[tmp_path] + _SPHINX_TESTAPP_COUNTER[tmp_path] += 1 + + """Provides the 'sphinx.application.Sphinx' object""" + def wrap(*args, **kwargs) -> Iterator[SphinxTestApp]: + assert kwargs.get('srcdir'), 'srcdir keyword argument missing' + + caplog.set_level(logging.DEBUG) + logger = logging.getLogger() + + basedir = tmp_path / f'sphinx-testapp-{testapp_id}' + srcdir = basedir / 'src' + builddir = basedir / 'build' + + shutil.copytree(kwargs['srcdir'], srcdir, dirs_exist_ok=True) + kwargs['srcdir'] = srcdir + + kwargs.setdefault('builddir', builddir) + + app_ = make_app(*args, **kwargs) + + logger.debug(json.dumps({ + 'builder': app_.builder.name, + 'srcdir': str(app_.srcdir), + 'outdir': str(app_.outdir) + }, indent = 4)) + + return app_ + + return wrap diff --git a/src/byteb4rb1e_sphinxcontrib/authorship/__init__.py b/src/byteb4rb1e_sphinxcontrib/authorship/__init__.py deleted file mode 100644 index ade89a5..0000000 --- a/src/byteb4rb1e_sphinxcontrib/authorship/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from sphinx.application import Sphinx -from sphinx.util.typing import ExtensionMetadata - - -def setup(app: Sphinx) -> ExtensionMetadata: - """add this extension and its children to a Sphinx application""" - return {} diff --git a/src/byteb4rb1e_sphinxcontrib/authorship/parsers/__init__.py b/src/byteb4rb1e_sphinxcontrib/authorship/parsers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/byteb4rb1e_sphinxcontrib/authorship/parsers/rst/__init__.py b/src/byteb4rb1e_sphinxcontrib/authorship/parsers/rst/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/byteb4rb1e_sphinxcontrib/authorship/parsers/rst/directives/__init__.py b/src/byteb4rb1e_sphinxcontrib/authorship/parsers/rst/directives/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/byteb4rb1e_sphinxcontrib/authorship/parsers/rst/directives/contribution.py b/src/byteb4rb1e_sphinxcontrib/authorship/parsers/rst/directives/contribution.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/byteb4rb1e_sphinxcontrib/svc/__init__.py b/src/byteb4rb1e_sphinxcontrib/svc/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/byteb4rb1e_sphinxcontrib/svc_authorship/__init__.py b/src/byteb4rb1e_sphinxcontrib/svc_authorship/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/integration/_fixtures/srcdir/byteb4rb1e/sphinxcontrib/ext/html_static_archive/default/_build/doctrees/index.doctree b/tests/integration/_fixtures/srcdir/byteb4rb1e/sphinxcontrib/ext/html_static_archive/default/_build/doctrees/index.doctree new file mode 100644 index 0000000..9765c4c Binary files /dev/null and b/tests/integration/_fixtures/srcdir/byteb4rb1e/sphinxcontrib/ext/html_static_archive/default/_build/doctrees/index.doctree differ diff --git a/src/byteb4rb1e_sphinxcontrib/__init__.py b/tests/integration/_fixtures/srcdir/byteb4rb1e/sphinxcontrib/ext/html_static_archive/default/conf.py similarity index 100% rename from src/byteb4rb1e_sphinxcontrib/__init__.py rename to tests/integration/_fixtures/srcdir/byteb4rb1e/sphinxcontrib/ext/html_static_archive/default/conf.py diff --git a/src/byteb4rb1e_sphinxcontrib/abc.py b/tests/integration/_fixtures/srcdir/byteb4rb1e/sphinxcontrib/ext/html_static_archive/default/index.rst similarity index 100% rename from src/byteb4rb1e_sphinxcontrib/abc.py rename to tests/integration/_fixtures/srcdir/byteb4rb1e/sphinxcontrib/ext/html_static_archive/default/index.rst diff --git a/tests/integration/byteb4rb1e/sphinxcontrib/ext/test_html_static_archive.py b/tests/integration/byteb4rb1e/sphinxcontrib/ext/test_html_static_archive.py new file mode 100644 index 0000000..9de8a8e --- /dev/null +++ b/tests/integration/byteb4rb1e/sphinxcontrib/ext/test_html_static_archive.py @@ -0,0 +1,117 @@ +from pathlib import Path +import tarfile + +import pytest + +from byteb4rb1e.sphinxcontrib.testing.pytest.fixtures import ( + mock_sphinx_testapp, + mock_sphinx_testsrc_dir +) +from byteb4rb1e.testing.pytest.decorators import run_in_subprocess_once +from byteb4rb1e.testing.pytest.fixtures import mock_system_site_package_dir + + + +@run_in_subprocess_once() +def test_default( + mock_sphinx_testapp, + mock_sphinx_testsrc_dir, + mock_system_site_package_dir, + tmp_path, +) -> None: + """ + """ + pkg_dir = mock_system_site_package_dir('dummypkg') + + mock_static_archive = tarfile.open(pkg_dir / 'data.tar', 'w') + + (tmp_path / 'foobar.txt').write_text("Hello world!") + + mock_static_archive.addfile( + mock_static_archive.gettarinfo( + arcname='foobar.txt', + fileobj=(tmp_path / 'foobar.txt').open('r') + ) + ) + + mock_static_archive.close() + + app = mock_sphinx_testapp(srcdir=mock_sphinx_testsrc_dir()) + + app.setup_extension('byteb4rb1e.sphinxcontrib.ext.html_static_archive') + + app.config['html_static_archive'] = f'pkg://dummypkg/data.tar' + + app.build(force_all=True) + + assert (Path(app.outdir) / '_static' / 'foobar.txt').exists() + + +@run_in_subprocess_once() +def test_compression( + mock_sphinx_testapp, + mock_sphinx_testsrc_dir, + mock_system_site_package_dir, + tmp_path, +) -> None: + """ + """ + pkg_dir = mock_system_site_package_dir('dummypkg') + + mock_static_archive = tarfile.open(pkg_dir / 'data.tar.gz', 'w:gz') + + (tmp_path / 'foobar.txt').write_text("Hello world!") + + mock_static_archive.addfile( + mock_static_archive.gettarinfo( + arcname='foobar.txt', + fileobj=(tmp_path / 'foobar.txt').open('r') + ) + ) + + mock_static_archive.close() + + app = mock_sphinx_testapp(srcdir=mock_sphinx_testsrc_dir()) + + app.setup_extension('byteb4rb1e.sphinxcontrib.ext.html_static_archive') + + app.config['html_static_archive'] = f'pkg://dummypkg/data.tar.gz' + + app.build(force_all=True) + + assert (Path(app.outdir) / '_static' / 'foobar.txt').exists() + + +@run_in_subprocess_once() +def test_subdirs( + mock_sphinx_testapp, + mock_sphinx_testsrc_dir, + mock_system_site_package_dir, + tmp_path, +) -> None: + """ + """ + pkg_dir = mock_system_site_package_dir('dummypkg') + + mock_static_archive = tarfile.open(pkg_dir / 'data.tar.gz', 'w:gz') + + (tmp_path / 'foobar.txt').write_text("Hello world!") + + mock_static_archive.addfile( + mock_static_archive.gettarinfo( + arcname='foo/bar/foobar.txt', + fileobj=(tmp_path / 'foobar.txt').open('r') + ) + ) + + mock_static_archive.close() + + app = mock_sphinx_testapp(srcdir=mock_sphinx_testsrc_dir()) + + app.setup_extension('byteb4rb1e.sphinxcontrib.ext.html_static_archive') + + app.config['html_static_archive'] = f'pkg://dummypkg/data.tar.gz' + + app.build(force_all=True) + + assert (Path(app.outdir) / '_static' / 'foo' / 'bar' / 'foobar.txt').exists() diff --git a/tests/integration/byteb4rb1e/sphinxcontrib/testing/pytest/test_fixtures.py b/tests/integration/byteb4rb1e/sphinxcontrib/testing/pytest/test_fixtures.py new file mode 100644 index 0000000..3369ee6 --- /dev/null +++ b/tests/integration/byteb4rb1e/sphinxcontrib/testing/pytest/test_fixtures.py @@ -0,0 +1,31 @@ + +import pytest + +pytestmark = pytest.mark.pytest + +from byteb4rb1e.sphinxcontrib.testing.pytest.fixtures import ( + mock_sphinx_testapp, + mock_sphinx_testsrc_dir +) + + +def test_mock_sphinx_testsrc_dir(mock_sphinx_testsrc_dir): + """ + """ + srcdir = mock_sphinx_testsrc_dir() + + assert (srcdir / 'conf.py').exists() + assert (srcdir / 'index.rst').exists() + + +def test_mock_sphinx_testapp(mock_sphinx_testapp, mock_sphinx_testsrc_dir): + """ + """ + srcdir = mock_sphinx_testsrc_dir() + + app = mock_sphinx_testapp(srcdir=srcdir) + + assert app.builder is not None + assert app.srcdir.samefile(srcdir) is False # Should be copied to internal src + + app.build(force_all=True) diff --git a/tests/integration/byteb4rb1e_sphinxcontrib/test_authorship.py b/tests/integration/byteb4rb1e_sphinxcontrib/test_authorship.py deleted file mode 100644 index 3519ad0..0000000 --- a/tests/integration/byteb4rb1e_sphinxcontrib/test_authorship.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_default() -> None: - assert 1 == 1 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..7c5f5e3 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,17 @@ +from pathlib import Path + +import pytest + +pytest_plugins = ['byteb4rb1e.sphinxcontrib.testing.pytest.fixtures'] + +_TESTS_ROOT = Path(__file__).resolve().parent + +def pytest_configure(config): + # register an additional marker + config.addinivalue_line( + "markers", "pytest: test pytest integration" + ) + +@pytest.fixture(scope='session') +def rootdir() -> Path: + return _TESTS_ROOT diff --git a/tox.ini b/tox.ini index b81dd10..d1fd388 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,9 @@ requires = tox>=4.19 env_list = - py3{8-12}-{unit} - py3{8-12}-sphinx{5-8}-{integration} + unit-py3{9-13} + integration-py3{9-13}-sphinx{5-8} + integration-py3{9-13}-sphinx{5-8}-pytest8 lint format @@ -35,7 +36,7 @@ deps = commands = black --check src tests -[testenv:py3{9-13}-unit] +[testenv:unit-py3{9-13}] description = run type check on code base labels = unit deps = @@ -44,8 +45,8 @@ deps = commands = pytest tests/unit --junitxml=test-reports/{env_name}.xml -[testenv:py3{9-13}-sphinx{5-7}-integration] -description = run type check on code base +[testenv:integration-py3{9-13}-sphinx{5-7}] +description = run sphinx 5-7 integration tests labels = integration deps = {[testenv]deps} @@ -56,8 +57,8 @@ deps = commands = pytest tests/integration --junitxml=test-reports/{env_name}.xml -[testenv:py3{10-13}-sphinx8-integration] -description = run type check on code base +[testenv:integration-py3{10-13}-sphinx8] +description = run sphinx 8 integration tests labels = integration deps = {[testenv]deps} @@ -65,3 +66,16 @@ deps = pytest commands = pytest tests/integration --junitxml=test-reports/{env_name}.xml + +[testenv:integration-py3{10-13}-sphinx{5-8}-pytest8] +description = run pytest 8 testing integration tests (excluding Python 3.9) +labels = integration +deps = + {[testenv]deps} + sphinx5: sphinx>=5.0,<=6.0 + sphinx6: sphinx>=6.0,<=7.0 + sphinx7: sphinx>=7.0,<=8.0 + sphinx8: sphinx>=8.0,<=9.0 + pytest8: pytest>=8.0,<=9.0 +commands = + pytest tests/integration -m pytest --junitxml=test-reports/{env_name}.xml