├── .github ├── dependabot.yml └── workflows │ └── ci.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── nbcollection ├── __init__.py ├── __main__.py ├── commands │ ├── __init__.py │ ├── argparse_helpers.py │ ├── convert.py │ └── execute.py ├── config.py ├── converter.py ├── logger.py ├── nb_helpers.py ├── notebook.py └── themes │ ├── __init__.py │ └── learnastropy │ ├── __init__.py │ ├── html.py │ ├── templates │ └── html │ │ ├── astropy-footer.html.j2 │ │ ├── astropy-header.html.j2 │ │ ├── astropy-sidebar.html.j2 │ │ ├── astropytutorial.css │ │ ├── conf.json │ │ └── index.html.j2 │ └── tocpreprocessor.py ├── pyproject.toml ├── tests ├── __init__.py ├── data │ ├── default.tpl │ ├── exception-should-fail.ipynb │ ├── exception-should-pass.ipynb │ ├── my_notebooks │ │ ├── notebook1.ipynb │ │ └── sub_path1 │ │ │ ├── notebook2.ipynb │ │ │ └── notebook3.ipynb │ ├── nb_test1 │ │ └── notebook1.ipynb │ ├── nb_test2 │ │ ├── notebook1.ipynb │ │ └── notebook2.ipynb │ └── nb_test3 │ │ ├── nb1 │ │ └── notebook1.ipynb │ │ └── nb2 │ │ └── notebook2.ipynb ├── support │ ├── __init__.py │ └── themes.py ├── test_cli.py └── themes │ ├── __init__.py │ ├── data │ ├── README.md │ └── color-excess.ipynb │ └── learnastropy │ ├── __init__.py │ └── test_html.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" # See documentation for possible values 9 | directory: ".github/workflows" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | groups: 13 | actions: 14 | patterns: 15 | - "*" 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | 'on': 4 | push: 5 | branches: 6 | - main 7 | - 'v*' 8 | tags: 9 | - '*' 10 | pull_request: 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | 18 | lint: 19 | 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 26 | with: 27 | python-version: "3.11" 28 | 29 | - name: Run pre-commit 30 | uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 31 | 32 | typing: 33 | 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 38 | 39 | - name: Run tox 40 | uses: lsst-sqre/run-tox@0be7e8464864caa0abeea2fe73246a9261aedc7f # v1.4.1 41 | with: 42 | python-version: "3.11" 43 | tox-envs: "typing" 44 | 45 | test: 46 | 47 | runs-on: ubuntu-latest 48 | 49 | strategy: 50 | matrix: 51 | python: 52 | - "3.9" 53 | - "3.10" 54 | - "3.11" 55 | 56 | steps: 57 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 58 | 59 | - name: Run tox 60 | uses: lsst-sqre/run-tox@0be7e8464864caa0abeea2fe73246a9261aedc7f # v1.4.1 61 | with: 62 | python-version: ${{ matrix.python }} 63 | tox-envs: "py" 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore test outputs 2 | tests/**/_build 3 | _build/ 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_prs: false 3 | autoupdate_schedule: 'monthly' 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.4.0 8 | hooks: 9 | - id: check-added-large-files 10 | # Prevent giant files from being committed. 11 | - id: check-case-conflict 12 | # Check for files with names that would conflict on a case-insensitive 13 | # filesystem like MacOS HFS+ or Windows FAT. 14 | - id: check-json 15 | # Attempts to load all json files to verify syntax. 16 | - id: check-merge-conflict 17 | # Check for files that contain merge conflict strings. 18 | - id: check-symlinks 19 | # Checks for symlinks which do not point to anything. 20 | - id: check-toml 21 | # Attempts to load all TOML files to verify syntax. 22 | - id: check-xml 23 | # Attempts to load all xml files to verify syntax. 24 | - id: check-yaml 25 | # Attempts to load all yaml files to verify syntax. 26 | exclude: ".*(.github.*)$" 27 | - id: detect-private-key 28 | # Checks for the existence of private keys. 29 | - id: end-of-file-fixer 30 | # Makes sure files end in a newline and only a newline. 31 | exclude: ".*(data.*|extern.*|licenses.*|_static.*|_parsetab.py)$" 32 | # - id: fix-encoding-pragma # covered by pyupgrade 33 | - id: trailing-whitespace 34 | # Trims trailing whitespace. 35 | exclude_types: [python] # Covered by Ruff W291. 36 | exclude: ".*(data.*|extern.*|licenses.*|_static.*)$" 37 | 38 | - repo: https://github.com/pre-commit/pygrep-hooks 39 | rev: v1.10.0 40 | hooks: 41 | - id: python-check-mock-methods 42 | # Prevent common mistakes of assert mck.not_called(), assert 43 | # mck.called_once_with(...) and mck.assert_called. 44 | - id: rst-directive-colons 45 | # Detect mistake of rst directive not ending with double colon. 46 | - id: rst-inline-touching-normal 47 | # Detect mistake of inline code touching normal text in rst. 48 | - id: text-unicode-replacement-char 49 | # Forbid files which have a UTF-8 Unicode replacement character. 50 | - id: python-check-blanket-noqa 51 | # Enforce that all noqa annotations always occur with specific codes. 52 | 53 | - repo: https://github.com/codespell-project/codespell 54 | rev: v2.2.5 55 | hooks: 56 | - id: codespell 57 | args: ["--write-changes"] 58 | additional_dependencies: 59 | - tomli 60 | exclude: "^tests/themes/data/" 61 | 62 | - repo: https://github.com/kynan/nbstripout 63 | rev: 0.6.1 64 | hooks: 65 | - id: nbstripout 66 | args: [ 67 | "--extra-keys", 68 | "metadata.kernelspec", 69 | ] 70 | # These notebooks should keep outputs for testing themes independently. 71 | exclude: "^tests/themes/data/" 72 | 73 | - repo: https://github.com/psf/black 74 | rev: 23.9.1 75 | hooks: 76 | - id: black 77 | 78 | - repo: https://github.com/astral-sh/ruff-pre-commit 79 | rev: "v0.0.291" 80 | hooks: 81 | - id: ruff 82 | args: ["--fix"] 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018–2020, Adrian Price-Whelan, Erik Tollerud 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nbcollection 2 | 3 | Tools for building collections of Jupyter notebooks into web pages for public 4 | consumption. 5 | 6 | This project serves as a thin wrapper around `nbconvert` to enable converting 7 | and executing directories or directory structures full of Jupyter notebooks to 8 | static HTML pages. 9 | 10 | ## License 11 | 12 | `nbcollection` is free software made available under the MIT License. For details 13 | see the LICENSE file. 14 | 15 | -------- 16 | 17 | ## Example usage 18 | 19 | ### Default behavior: 20 | 21 | #### Converting a directory structure of specific notebook files 22 | 23 | Imagine we have a directory containing Jupyter notebook files and some other 24 | sub-directories that also contain notebook files, such as: 25 | 26 | my_notebooks 27 | ├── notebook1.ipynb 28 | └── sub_path1 29 | ├── notebook2.ipynb 30 | └── notebook3.ipynb 31 | 32 | From the top level, we could use `nbcollection` to execute and convert all of these 33 | notebook files to HTML by running: 34 | 35 | nbcollection convert my_notebooks 36 | 37 | With no options specified, this will create a directory within the specified 38 | path, `my_notebooks/_build`, to store the executed notebooks and the converted 39 | HTML pages: 40 | 41 | my_notebooks 42 | └── _build 43 | ├── notebook1.ipynb 44 | ├── notebook1.html 45 | └── sub_path1 46 | ├── notebook2.ipynb 47 | ├── notebook3.ipynb 48 | ├── notebook2.html 49 | └── notebook3.html 50 | 51 | If you are only interested in executing the notebooks, you can instead use the 52 | `execute` command: 53 | 54 | nbcollection execute my_notebooks 55 | 56 | which still creates a new `_build` path but now only contains the executed 57 | notebook files: 58 | 59 | my_notebooks 60 | └── _build 61 | ├── notebook1.ipynb 62 | └── sub_path1 63 | ├── notebook2.ipynb 64 | └── notebook3.ipynb 65 | 66 | 67 | #### Converting a list of specific notebook files 68 | 69 | Instead of running on a full directory, it is also possible to convert or 70 | execute single notebook files (but you should probably use `jupyter nbconvert` 71 | directly), or lists of notebook files. For example, to convert a set of specific 72 | notebook files within the above example directory layout: 73 | 74 | nbcollection convert my_notebooks/notebook1.ipynb my_notebooks/sub_path1/notebook2.ipynb 75 | 76 | Because these files could in principle be in two completely separate paths, the 77 | build products here will instead be written to the current working directory by 78 | default (but see the command option `--build-path` below to customize). So, the 79 | above command would result in: 80 | 81 | _build 82 | ├── notebook1.ipynb 83 | └── sub_path1 84 | └── notebook2.ipynb 85 | 86 | 87 | ### Command options: 88 | 89 | Several options are available to modify the default behavior of the `nbcollection` 90 | commands. 91 | 92 | #### Customizing the build path 93 | 94 | As outlined above, the default locations for storing the executed notebooks or 95 | converted HTML pages is either in a parallel directory structure contained 96 | within a `_build` directory created at the top-level of the specified path, or 97 | in a `_build` path in the current working directory (if a list of notebook files 98 | are specified). However, the build path can be overridden and specified 99 | explicitly by specifying the `--build-path` command line flag. For example, with 100 | the notebook directory structure illustrated in the above examples, we could 101 | instead specify the build path with: 102 | 103 | nbcollection convert my_notebooks --build-path=/new/path/my_build 104 | 105 | With this option specified, the executed notebook files and converted HTML 106 | notebooks would be placed under `/new/path/my_build` instead. 107 | 108 | 109 | #### Flattening the built file structure 110 | 111 | If your notebook files are spread throughout a nested directory structure, you 112 | may want to place all of the converted notebook files in a single path rather 113 | than reproduce the relative path structure of your content. To enable this, use 114 | the `--flatten` boolean flag. For example, if your content has the following 115 | path structure: 116 | 117 | my_notebooks 118 | ├── notebook1.ipynb 119 | └── sub_path1 120 | ├── notebook2.ipynb 121 | └── notebook3.ipynb 122 | 123 | You can convert all of the notebooks to a single build path with: 124 | 125 | nbcollection convert my_notebooks --flatten 126 | 127 | This will result in: 128 | 129 | my_notebooks 130 | └── _build 131 | ├── notebook1.ipynb 132 | ├── notebook2.ipynb 133 | ├── notebook3.ipynb 134 | ├── notebook1.html 135 | ├── notebook2.html 136 | └── notebook3.html 137 | 138 | This command also works in conjunction with `--build-path` if you want to, e.g., 139 | convert a list of individual notebook files and have the build products end up 140 | in the same root path. 141 | 142 | 143 | #### Specifying a custom template file 144 | 145 | `nbconvert` allows specifying custom `jinja2` [template 146 | files](https://nbconvert.readthedocs.io/en/latest/customizing.html) for 147 | exporting notebook files to HTML. We support this through the `--template` 148 | command-line flag, which allows specifying a path to a `jinja2` template file. 149 | For example: 150 | 151 | nbcollection convert my_notebooks --template-file=templates/custom.tpl 152 | 153 | #### Extracting figures and other preprocessors 154 | 155 | You can enable additional 156 | [preprocessors](https://nbconvert.readthedocs.io/en/latest/api/preprocessors.html#specialized-preprocessors) 157 | for the HTML exporter by passing one or more preprocessor names to the 158 | `--preprocessors` option. A useful preprocessor is `ExtractOutputPreprocessor`, 159 | which extracts figures from the HTML into separate files for better page loading 160 | performance: 161 | 162 | nbcollection convert my_notebooks --preprocessors=nbconvert.preprocessors.ExtractOutputPreprocessor 163 | 164 | #### Only execute the notebooks 165 | 166 | Though the primary utility of `nbcollection` is to enable converting a collection of 167 | notebook files to static HTML pages, you can also use the `nbcollection execute` 168 | command to instead only execute a collection of notebooks. This command is used 169 | the same way as `nbcollection convert`, but also enable executing the notebook files 170 | in place as a way to test the notebooks. To execute a collection of notebooks 171 | in-place (i.e., this will not create a `_build` path with the executed 172 | notebooks): 173 | 174 | nbcollection execute my_notebooks --inplace 175 | -------------------------------------------------------------------------------- /nbcollection/__init__.py: -------------------------------------------------------------------------------- 1 | """Execute and convert collections of Jupyter notebooks to static websites.""" 2 | -------------------------------------------------------------------------------- /nbcollection/__main__.py: -------------------------------------------------------------------------------- 1 | """nbcollection command-line interface.""" 2 | 3 | import argparse 4 | import sys 5 | 6 | from .commands import convert, execute 7 | 8 | commands = {"execute": execute, "convert": convert} 9 | 10 | DESCRIPTION = """Type `nbcollection -h` for help. 11 | 12 | The allowed commands are: 13 | 14 | nbcollection execute 15 | nbcollection convert 16 | """ 17 | 18 | parser = argparse.ArgumentParser( 19 | description=DESCRIPTION, formatter_class=argparse.RawTextHelpFormatter 20 | ) 21 | parser.add_argument( 22 | "command", 23 | help=f"The command you'd like to run. Allowed commands: {list(commands.keys())}", 24 | ) 25 | 26 | 27 | def main(args=None): 28 | """Run the nbconvert CLI.""" 29 | args = args or sys.argv 30 | parsed = parser.parse_args(args[1:2]) 31 | if parsed.command not in commands: 32 | parser.print_help() 33 | msg = ( 34 | f"Unrecognized command: {parsed.command}\n See the help above for usage " 35 | "information" 36 | ) 37 | raise ValueError(msg) 38 | 39 | # Run the command 40 | commands[parsed.command](args) 41 | 42 | 43 | if __name__ == "__main__": 44 | main() 45 | -------------------------------------------------------------------------------- /nbcollection/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Defines the commands that the CLI will use.""" 2 | 3 | from .convert import convert 4 | from .execute import execute 5 | 6 | __all__ = ["convert", "execute"] 7 | -------------------------------------------------------------------------------- /nbcollection/commands/argparse_helpers.py: -------------------------------------------------------------------------------- 1 | """Utility functions for argparse.""" 2 | 3 | import logging 4 | import sys 5 | from argparse import ArgumentParser 6 | 7 | import traitlets 8 | 9 | from nbcollection.config import NbcollectionConfig 10 | from nbcollection.converter import NbcollectionConverter 11 | from nbcollection.logger import logger 12 | 13 | _trait_type_map = {traitlets.Unicode: str, traitlets.Int: int, traitlets.Bool: bool} 14 | 15 | execute_trait_names = ["kernel_name", "timeout", "allow_errors"] 16 | convert_trait_names = [ 17 | "template_file", 18 | ] # TODO: add exclude_*? 19 | 20 | 21 | def set_log_level(args, logger): 22 | """Set the logger's level given the CLI arguments.""" 23 | if args.verbosity == 1: 24 | log_level = logging.DEBUG 25 | 26 | elif args.verbosity == 2: 27 | log_level = 1 28 | 29 | elif args.verbosity == 3: 30 | log_level = 0 31 | 32 | elif args.quietness == 1: 33 | log_level = logging.WARNING 34 | 35 | elif args.quietness == 2: 36 | log_level = logging.ERROR 37 | 38 | else: 39 | log_level = logging.INFO # default 40 | 41 | logger.setLevel(log_level) 42 | 43 | 44 | def get_parser(description): 45 | """Create an argument parser for the CLI.""" 46 | 47 | class CustomArgumentParser(ArgumentParser): 48 | def parse_args(self, *args, **kwargs): 49 | parsed = super().parse_args(*args, **kwargs) 50 | set_log_level(parsed, logger) 51 | 52 | if parsed.notebooks is None and not sys.stdin.isatty(): 53 | stdin = sys.stdin.read().strip() 54 | parsed.notebooks = stdin.split() 55 | 56 | return parsed 57 | 58 | parser = CustomArgumentParser(description=description) 59 | 60 | parser.add_argument( 61 | "notebooks", 62 | nargs="*", 63 | default=None, 64 | help="Path to the root directory containing Jupyter " 65 | "notebooks, to a single notebook file, or a " 66 | "list of notebook files.", 67 | ) 68 | 69 | parser.add_argument( 70 | "--build-path", 71 | dest="build_path", 72 | help="The path to save all executed or converted " 73 | "notebook files. If not specified, the executed/" 74 | "converted files will be in _build", 75 | ) 76 | 77 | parser.add_argument( 78 | "--flatten", 79 | action="store_true", 80 | dest="flatten", 81 | default=False, 82 | help="Flatten the directory structure of the built " 83 | "notebooks. All HTML notebook files will be " 84 | "written to the top-level build path.", 85 | ) 86 | 87 | parser.add_argument( 88 | "-o", 89 | "--overwrite", 90 | action="store_true", 91 | dest="overwrite", 92 | help="Overwrite executed notebooks if they already exist.", 93 | ) 94 | 95 | parser.add_argument( 96 | "--exclude", 97 | dest="exclude_pattern", 98 | help="A regular expression to match against files in " 99 | "the target path, used to exclude files", 100 | ) 101 | 102 | parser.add_argument( 103 | "--include", 104 | dest="include_pattern", 105 | help="A regular expression to match against files in " 106 | "the target path, used to include files", 107 | ) 108 | 109 | vq_group = parser.add_mutually_exclusive_group() 110 | vq_group.add_argument( 111 | "-v", "--verbose", action="count", default=0, dest="verbosity" 112 | ) 113 | vq_group.add_argument("-q", "--quiet", action="count", default=0, dest="quietness") 114 | 115 | return parser 116 | 117 | 118 | def get_converter(args): 119 | """Create an NbcollectionConverter instance from the CLI configuration.""" 120 | kw = {} 121 | 122 | execute_kw = {} 123 | for k in execute_trait_names: 124 | if hasattr(args, k): 125 | execute_kw[k] = getattr(args, k) 126 | 127 | convert_kw = {} 128 | for k in convert_trait_names: 129 | if hasattr(args, k): 130 | convert_kw[k] = getattr(args, k) 131 | 132 | kw["execute_kwargs"] = execute_kw 133 | kw["convert_kwargs"] = convert_kw 134 | 135 | # Bit of a hack, but it seems like explicitly passing a value for 136 | # template_file (even if it is the default, null value) raises an error in 137 | # nbconvert 138 | kw["convert_kwargs"].pop("template_file", None) 139 | 140 | if hasattr(args, "preprocessors"): 141 | kw["convert_preprocessors"] = args.preprocessors 142 | 143 | # Process the other flags: 144 | kwargs = vars(args) 145 | for k in kwargs: 146 | if k in list(execute_kw.keys()) + list(convert_kw.keys()): 147 | continue 148 | kw[k] = getattr(args, k) 149 | 150 | config_args = {} 151 | try: 152 | # These arguments are available for the execute command 153 | config_args["github_repo_url"] = args.github_repo_url 154 | config_args["github_repo_path"] = args.github_repo_path 155 | config_args["github_repo_branch"] = args.github_repo_branch 156 | except AttributeError: 157 | pass 158 | config = NbcollectionConfig(**config_args) 159 | kw["config"] = config 160 | 161 | return NbcollectionConverter(**kw) 162 | -------------------------------------------------------------------------------- /nbcollection/commands/convert.py: -------------------------------------------------------------------------------- 1 | """The nbcollection convert command.""" 2 | 3 | import sys 4 | 5 | from nbconvert.exporters import HTMLExporter 6 | 7 | from .argparse_helpers import ( 8 | _trait_type_map, 9 | convert_trait_names, 10 | get_converter, 11 | get_parser, 12 | ) 13 | 14 | DESCRIPTION = "Convert a collection of Jupyter notebooks to HTML" 15 | 16 | 17 | def convert(args=None): 18 | """Run the convert command.""" 19 | args = args or sys.argv 20 | 21 | parser = get_parser(DESCRIPTION) 22 | 23 | # Specific to this command: 24 | parser.add_argument( 25 | "--index-template", 26 | dest="index_template", 27 | default=None, 28 | type=str, 29 | help="A jinja2 template file used to create the index page.", 30 | ) 31 | 32 | parser.add_argument( 33 | "--make-index", 34 | dest="make_index", 35 | default=False, 36 | action="store_true", 37 | help="Controls whether to make an index page that " 38 | "lists all of the converted notebooks.", 39 | ) 40 | 41 | parser.add_argument( 42 | "--preprocessors", 43 | nargs="*", 44 | default=[], 45 | help="Preprocessors for convert. For example, " 46 | "nbconvert.preprocessors.ExtractOutputPreprocessor", 47 | ) 48 | 49 | parser.add_argument( 50 | "--github-url", 51 | dest="github_repo_url", 52 | default=None, 53 | type=str, 54 | help="URL of the GitHub repository hosting the notebooks.", 55 | ) 56 | 57 | parser.add_argument( 58 | "--github-path", 59 | dest="github_repo_path", 60 | default="", 61 | type=str, 62 | help=( 63 | "Root path of the notebooks inside the GitHub repository. (default is " 64 | "the root)", 65 | ), 66 | ) 67 | 68 | parser.add_argument( 69 | "--github-branch", 70 | dest="github_repo_branch", 71 | default="main", 72 | type=str, 73 | help="Branch or tag of the GitHub repository to use. (default is main)", 74 | ) 75 | 76 | for trait_name in convert_trait_names: 77 | trait = getattr(HTMLExporter, trait_name) 78 | parser.add_argument( 79 | "--" + trait_name.replace("_", "-"), 80 | default=trait.default_value, 81 | type=_trait_type_map[type(trait)], 82 | help=trait.help, 83 | ) 84 | 85 | args = parser.parse_args(args[2:]) 86 | nbcollection = get_converter(args) 87 | nbcollection.convert() 88 | 89 | if args.make_index: 90 | nbcollection.make_html_index(args.index_template) 91 | -------------------------------------------------------------------------------- /nbcollection/commands/execute.py: -------------------------------------------------------------------------------- 1 | """The nbcollection execute command.""" 2 | 3 | import sys 4 | 5 | from nbconvert.preprocessors import ExecutePreprocessor 6 | 7 | from .argparse_helpers import ( 8 | _trait_type_map, 9 | execute_trait_names, 10 | get_converter, 11 | get_parser, 12 | ) 13 | 14 | DESCRIPTION = "Execute a collection of Jupyter notebooks" 15 | 16 | 17 | def execute(args=None): 18 | """Run the execute command.""" 19 | args = args or sys.argv 20 | 21 | parser = get_parser(DESCRIPTION) 22 | 23 | # Specific to this command: 24 | for trait_name in execute_trait_names: 25 | trait = getattr(ExecutePreprocessor, trait_name) 26 | parser.add_argument( 27 | "--" + trait_name.replace("_", "-"), 28 | default=trait.default_value, 29 | type=_trait_type_map[type(trait)], 30 | help=trait.help, 31 | ) 32 | 33 | args = parser.parse_args(args[2:]) 34 | nbcollection = get_converter(args) 35 | nbcollection.execute() 36 | -------------------------------------------------------------------------------- /nbcollection/config.py: -------------------------------------------------------------------------------- 1 | """Nbcollection configuration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from urllib.parse import urlparse 7 | 8 | __all__ = ["NbcollectionConfig"] 9 | 10 | 11 | @dataclass 12 | class NbcollectionConfig: 13 | """Nbcollection configuration. 14 | 15 | Attributes 16 | ---------- 17 | github_repo_url : str or None 18 | URL of the GitHub repository hosting the notebooks. 19 | github_repo_path : str 20 | Root path of the notebooks inside the GitHub repository. Default is 21 | an empty string corresponding to the root of the repository. 22 | github_repo_branch : str 23 | Branch of the GitHub repository to use. Default is "main". 24 | """ 25 | 26 | github_repo_url: str | None = None 27 | github_repo_path: str = "" 28 | github_repo_branch: str = "main" 29 | 30 | @property 31 | def github_owner(self) -> str | None: 32 | """GitHub owner name of the ``github_repo_url``.""" 33 | if self.github_repo_url is None: 34 | return None 35 | parsed_url = urlparse(self.github_repo_url) 36 | return parsed_url.path.split("/")[1] 37 | 38 | @property 39 | def github_repo(self) -> str | None: 40 | """GitHub repository name of the ``github_repo_url``.""" 41 | if self.github_repo_url is None: 42 | return None 43 | parsed_url = urlparse(self.github_repo_url) 44 | return parsed_url.path.split("/")[2].split(".")[0] 45 | -------------------------------------------------------------------------------- /nbcollection/converter.py: -------------------------------------------------------------------------------- 1 | """The nbcollection converter.""" 2 | 3 | # Standard library 4 | import os 5 | import re 6 | from pathlib import Path 7 | 8 | # Third-party 9 | import jinja2 10 | 11 | # Package 12 | from nbcollection.logger import logger 13 | from nbcollection.nb_helpers import get_title 14 | from nbcollection.notebook import NbcollectionNotebook 15 | 16 | from .config import NbcollectionConfig 17 | 18 | __all__ = ["NbcollectionConverter"] 19 | 20 | 21 | def get_output_path(nb_path, build_path, *, relative_root_path=None, flatten=False): 22 | """Compute the output path for a notebook. 23 | 24 | Parameters 25 | ---------- 26 | nb_path : str 27 | Path to the notebook file. 28 | build_path : str 29 | Path to the build directory. 30 | relative_root_path : str (optional) 31 | The path to the root directory containing the notebooks. This is used 32 | to determine the relative path to the notebook file in the output 33 | directory. 34 | flatten : bool (optional) 35 | Whether or not to flatten the directory structure of the output 36 | directory. 37 | 38 | Returns 39 | ------- 40 | str 41 | The path of the output file in the build directory. 42 | """ 43 | if relative_root_path is not None: 44 | common_prefix = os.path.commonpath([nb_path, relative_root_path]) 45 | if common_prefix == "/": 46 | # If there is no common prefix, write all notebooks directly to 47 | # the build directory. This is useful for testing and, e.g., 48 | # writing all executed notebooks to a temporary directory 49 | relative_path = "" 50 | # TODO: should we warn? 51 | else: 52 | relative_path = os.path.relpath(nb_path, common_prefix) 53 | else: 54 | relative_path = "" 55 | 56 | if flatten: # flatten the directory structure 57 | full_build_path = build_path 58 | else: 59 | full_build_path = os.path.abspath(os.path.join(build_path, relative_path)) 60 | os.makedirs(full_build_path, exist_ok=True) 61 | return full_build_path 62 | 63 | 64 | class NbcollectionConverter: 65 | """A class that executes and converting a collection of notebooks. 66 | 67 | Parameters 68 | ---------- 69 | notebooks : str, iterable 70 | Either a string path to a single notebook, a path to a collection of 71 | notebooks, or an iterable containing individual notebook files. 72 | overwrite : bool (optional) 73 | """ 74 | 75 | build_dir_name = "_build" 76 | 77 | def __init__( 78 | self, 79 | notebooks, 80 | *, 81 | config: NbcollectionConfig, 82 | overwrite=False, 83 | build_path=None, 84 | flatten=False, 85 | exclude_pattern=None, 86 | include_pattern=None, 87 | execute_kwargs=None, 88 | convert_kwargs=None, 89 | convert_preprocessors=None, 90 | **kwargs, # noqa: ARG002 91 | ) -> None: 92 | self._config = config 93 | 94 | if isinstance(notebooks, str) or len(notebooks) == 1: 95 | if isinstance(notebooks, str): 96 | notebooks = [notebooks] 97 | 98 | # This works whether the input is a single notebook file or a 99 | # directory containing notebooks: 100 | nb_path = os.path.split(notebooks[0])[0] 101 | default_build_path = os.path.join(nb_path, self.build_dir_name) 102 | self._relative_root_path = nb_path 103 | 104 | # The root source dir is the common directory containing all the 105 | # notebooks. If NbcollectionConfig.github_repo_path is set, then 106 | # this local directory corresponds to that path in the GitHub 107 | # repository. 108 | # Case 1: the input is the root source directory itself 109 | self._root_source_dir = Path(notebooks[0]).resolve() 110 | if self._root_source_dir.is_file(): 111 | # Case 2: the input is a single notebook file, so we use its 112 | # directory 113 | self._root_source_dir = self._root_source_dir.parent 114 | 115 | else: 116 | # Multiple paths were specified as a list, so we can't infer the 117 | # build path location - default to using the cwd: 118 | default_build_path = os.path.join(os.getcwd(), self.build_dir_name) 119 | self._relative_root_path = None 120 | # Case 3: set the root source directory to the current working 121 | # directory. 122 | self._root_source_dir = Path.cwd() 123 | 124 | if build_path is None: 125 | build_path = default_build_path 126 | else: 127 | build_path = os.path.join(build_path, self.build_dir_name) 128 | 129 | nbs = [] 130 | for notebook in notebooks: 131 | if os.path.isdir(notebook): 132 | # It's a directory, so we need to walk through recursively and 133 | # collect any notebook files 134 | for root, dirs, files in os.walk(notebook): 135 | for d in dirs: 136 | if d.startswith((".", "_")): 137 | # calling remove here actually modifies the paths 138 | # that os.walk will recursively explore 139 | dirs.remove(d) 140 | 141 | for name in files: 142 | basename, ext = os.path.splitext(name) 143 | file_path = os.path.join(root, name) 144 | 145 | if exclude_pattern is not None and re.search( 146 | exclude_pattern, name 147 | ): 148 | continue 149 | 150 | if ( 151 | include_pattern is not None 152 | and re.search(include_pattern, name) is None 153 | ): 154 | continue 155 | 156 | if ext == ".ipynb": 157 | # repo_path is the path to the notebook file 158 | # relative to the root repository directory 159 | repo_path = ( 160 | Path(file_path) 161 | .resolve() 162 | .relative_to(self._root_source_dir) 163 | .as_posix() 164 | ) 165 | nb = NbcollectionNotebook( 166 | file_path, 167 | output_path=get_output_path( 168 | file_path, 169 | build_path=build_path, 170 | relative_root_path=self._relative_root_path, 171 | flatten=flatten, 172 | ), 173 | config=self._config, 174 | repo_path=repo_path, 175 | overwrite=overwrite, 176 | execute_kwargs=execute_kwargs, 177 | convert_kwargs=convert_kwargs, 178 | convert_preprocessors=convert_preprocessors, 179 | ) 180 | nbs.append(nb) 181 | 182 | elif os.path.isfile(notebook): 183 | # It's a single file: 184 | 185 | # repo_path is the path to the notebook file 186 | # relative to the root repository directory 187 | repo_path = ( 188 | Path(notebook) 189 | .resolve() 190 | .relative_to(self._root_source_dir) 191 | .as_posix() 192 | ) 193 | nb = NbcollectionNotebook( 194 | notebook, 195 | output_path=get_output_path( 196 | notebook, 197 | build_path=build_path, 198 | relative_root_path=self._relative_root_path, 199 | flatten=flatten, 200 | ), 201 | config=self._config, 202 | repo_path=repo_path, 203 | overwrite=overwrite, 204 | execute_kwargs=execute_kwargs, 205 | convert_kwargs=convert_kwargs, 206 | ) 207 | nbs.append(nb) 208 | 209 | else: 210 | msg = ( 211 | "Input specification of notebooks not understood: File or path " 212 | f"does not exist {notebook}" 213 | ) 214 | raise ValueError(msg) 215 | 216 | logger.info(f"Collected {len(nbs)} notebook files") 217 | logger.debug(f"Executed/converted notebooks will be saved in: {build_path}") 218 | 219 | self.notebooks = nbs 220 | self.flatten = flatten 221 | self.build_path = build_path 222 | 223 | def execute(self, *, stop_on_error=False): 224 | exceptions = {} 225 | for nb in self.notebooks: 226 | try: 227 | nb.execute() 228 | except Exception as e: 229 | if stop_on_error: 230 | raise 231 | exceptions[nb.filename] = e 232 | 233 | if exceptions: 234 | for nb, exception in exceptions.items(): 235 | logger.error(f"Notebook '{nb}' errored: {exception!s}") 236 | msg = ( 237 | f"{len(exceptions)} notebooks raised unexpected errors while executing " 238 | f"cells: {list(exceptions.keys())} — see above for more details about " 239 | "the failing cells. If any of these are expected errors, add a Jupyter " 240 | "cell tag 'raises-exception' to the failing cells." 241 | ) 242 | raise RuntimeError(msg) 243 | 244 | def convert(self): 245 | for nb in self.notebooks: 246 | nb.convert() 247 | 248 | def make_html_index(self, template_file, output_filename="index.html"): 249 | """Generate an html index page for a set of notebooks. 250 | 251 | Parameters 252 | ---------- 253 | template_file : str 254 | A path to the template file to be used for generating the index. The 255 | template should be in jinja2 format and have a loop over 256 | ``notebook_html_paths`` to populate with the links 257 | output_filename : str or None 258 | the output file name, or None to not write the file 259 | 260 | Returns 261 | ------- 262 | content : str 263 | The content of the index file 264 | """ 265 | if isinstance(template_file, str): 266 | # Load jinja2 template for index page: 267 | path, fn = os.path.split(template_file) 268 | env = jinja2.Environment( 269 | loader=jinja2.FileSystemLoader(path), 270 | autoescape=jinja2.select_autoescape(["html", "xml"]), 271 | ) 272 | templ = env.get_template(fn) 273 | 274 | elif isinstance(template_file, jinja2.Template): 275 | templ = template_file 276 | 277 | else: 278 | msg = ( 279 | f"Unknown template file type '{type(template_file)}'. Must be either a " 280 | "string path or a jinja2 template instance." 281 | ) 282 | raise TypeError(msg) 283 | 284 | out_path = os.path.dirname(output_filename) 285 | if out_path == "": 286 | # By default, write the index file to the _build/ path 287 | out_path = self.build_path 288 | os.makedirs(out_path, exist_ok=True) 289 | 290 | notebook_metadata = [] 291 | for nb in self.notebooks: 292 | relpath = os.path.relpath(nb.html_path, out_path) 293 | 294 | notebook_metadata.append( 295 | {"html_path": relpath, "name": get_title(nb.exec_path)} 296 | ) 297 | 298 | content = templ.render(notebooks=notebook_metadata) 299 | with open(os.path.join(out_path, output_filename), "w") as f: 300 | f.write(content) 301 | 302 | return content 303 | -------------------------------------------------------------------------------- /nbcollection/logger.py: -------------------------------------------------------------------------------- 1 | """Custom logger for nbcollection.""" 2 | 3 | # mypy: ignore-errors 4 | 5 | # Standard library 6 | import logging 7 | 8 | __all__ = ["logger"] 9 | 10 | 11 | class CustomHandler(logging.StreamHandler): 12 | """A custom handler that prepends the logger name to the message.""" 13 | 14 | def emit(self, record): 15 | """Emit a formatted log record.""" 16 | record.msg = f"[nbcollection ({record.levelname})]: {record.msg}" 17 | super().emit(record) 18 | 19 | 20 | class CustomLogger(logging.getLoggerClass()): 21 | """A custom logger that sets up the custom handler.""" 22 | 23 | def _set_defaults(self): 24 | """Reset logger to its initial state""" 25 | # Remove all previous handlers 26 | for handler in self.handlers[:]: 27 | self.removeHandler(handler) 28 | 29 | # Set default level 30 | self.setLevel(logging.INFO) 31 | 32 | # Set up the stdout handler 33 | sh = CustomHandler() 34 | self.addHandler(sh) 35 | 36 | 37 | logging.setLoggerClass(CustomLogger) 38 | logger = logging.getLogger("nbcollection") 39 | logger._set_defaults() 40 | -------------------------------------------------------------------------------- /nbcollection/nb_helpers.py: -------------------------------------------------------------------------------- 1 | """Notebook utilities.""" 2 | 3 | import re 4 | 5 | import nbformat 6 | 7 | __all__ = ["is_executed", "get_title"] 8 | 9 | 10 | def is_executed(nb_path): 11 | """Determine whether the notebook file has been executed. 12 | 13 | Parameters 14 | ---------- 15 | nb_path : str 16 | The string path to a notebook file. 17 | 18 | Returns 19 | ------- 20 | is_executed : bool 21 | True if the notebook has been executed. 22 | """ 23 | nb = nbformat.read(nb_path, nbformat.NO_CONVERT) 24 | return any(cell.cell_type == "code" and cell.outputs for cell in nb.cells) 25 | 26 | 27 | def get_title(nb_path): 28 | """Get the title of a notebook by finding the first H1 header. 29 | 30 | Parameters 31 | ---------- 32 | nb_path : str 33 | The string path to a notebook file. 34 | 35 | Returns 36 | ------- 37 | title : str 38 | The string title. 39 | """ 40 | # read the first top-level header as the notebook title 41 | with open(nb_path) as f: 42 | nb = nbformat.read(f, as_version=4) # TODO: make config item? 43 | 44 | for cell in nb["cells"]: 45 | match = re.search("# (.*)", cell["source"]) 46 | 47 | if match: 48 | break 49 | 50 | else: 51 | msg = ( 52 | "Failed to find a title for the notebook. To include it in an index page, " 53 | "each notebook must have a H1 heading that is treated as the notebooks " 54 | "title." 55 | ) 56 | raise RuntimeError(msg) 57 | 58 | return match.groups()[0] 59 | -------------------------------------------------------------------------------- /nbcollection/notebook.py: -------------------------------------------------------------------------------- 1 | """Operations on an individual notebook.""" 2 | 3 | # Standard library 4 | import os 5 | import time 6 | from pathlib import PurePosixPath 7 | from urllib.parse import urlencode 8 | 9 | import nbformat 10 | 11 | # Third-party 12 | from nbconvert.preprocessors import CellExecutionError, ExecutePreprocessor 13 | from nbconvert.writers import FilesWriter 14 | from traitlets.config import Config 15 | 16 | # Package 17 | from nbcollection.logger import logger 18 | from nbcollection.nb_helpers import is_executed 19 | from nbcollection.themes.learnastropy.html import LearnAstropyHtmlExporter 20 | 21 | __all__ = ["NbcollectionNotebook"] 22 | 23 | 24 | class NbcollectionNotebook: 25 | """An individual notebook. 26 | 27 | Parameters 28 | ---------- 29 | file_path : str 30 | The path to a notebook file 31 | output_path : str (optional) 32 | The full path to a notebook 33 | overwrite : bool (optional) 34 | Whether or not to overwrite files. 35 | execute_kwargs : dict (optional) 36 | Keyword arguments passed through to 37 | ``nbconvert.ExecutePreprocessor``. 38 | convert_kwargs : dict (optional) 39 | Keyword arguments passed through to ``nbconvert.HTMLExporter``. 40 | convert_preprocessors : list of str (optional) 41 | The preprocessors enabled for the HTMLExporter. For example, 42 | ``"nbconvert.preprocessors.ExtractOutputPreprocessor"``. 43 | """ 44 | 45 | nbformat_version = 4 46 | 47 | def __init__( 48 | self, 49 | file_path, 50 | *, 51 | config, 52 | repo_path, 53 | output_path=None, 54 | overwrite=False, 55 | execute_kwargs=None, 56 | convert_kwargs=None, 57 | convert_preprocessors=None, 58 | ) -> None: 59 | self._config = config 60 | self._repo_path = repo_path 61 | 62 | if not os.path.exists(file_path): 63 | msg = f"Notebook file '{file_path}' does not exist" 64 | raise OSError(msg) 65 | 66 | if not os.path.isfile(file_path): 67 | msg = ( 68 | "Input notebook path must contain a filename, e.g., " 69 | f"/path/to/some/notebook.ipynb (received: {file_path})" 70 | ) 71 | raise ValueError(msg) 72 | 73 | # First, get the path and notebook filename separately: 74 | self.file_path = os.path.abspath(file_path) 75 | self.path, self.filename = os.path.split(self.file_path) 76 | 77 | # If no output path is specified, use the notebook path: 78 | if output_path is None: 79 | output_path = self.path 80 | 81 | # Get the notebook basename to construct the rendered HTML filename: 82 | self.basename = os.path.splitext(self.filename)[0] 83 | 84 | # Paths to executed and converted HTML notebook files: 85 | self.exec_path = os.path.join(output_path, f"{self.basename}.ipynb") 86 | self.html_path = os.path.join(output_path, f"{self.basename}.html") 87 | 88 | self.overwrite = overwrite 89 | 90 | if execute_kwargs is None: 91 | execute_kwargs = {} 92 | self.execute_kwargs = execute_kwargs 93 | 94 | if convert_kwargs is None: 95 | convert_kwargs = {} 96 | self.convert_kwargs = convert_kwargs 97 | 98 | self.converter_config = Config() 99 | if convert_preprocessors is not None: 100 | self.converter_config.HTMLExporter.preprocessors = convert_preprocessors 101 | 102 | def execute(self): 103 | """Execute this notebook file. 104 | 105 | The output notebook is written to a new file. 106 | 107 | Parameters 108 | ---------- 109 | overwrite : bool, optional 110 | Whether or not to overwrite an existing executed notebook file. 111 | 112 | Returns 113 | ------- 114 | executed_nb_path : str, ``None`` 115 | The path to the executed notebook. 116 | """ 117 | if ( 118 | os.path.exists(self.exec_path) 119 | and is_executed(self.exec_path) 120 | and not self.overwrite 121 | ): 122 | logger.debug( 123 | f"Executed notebook exists at '{self.exec_path}'. " 124 | "Use overwrite=True or set the config item " 125 | "exec_overwrite=True to overwrite." 126 | ) 127 | return self.exec_path 128 | 129 | # Execute the notebook 130 | logger.debug(f"Executing notebook '{self.filename}' ⏳") 131 | t0 = time.time() 132 | 133 | executor = ExecutePreprocessor(**self.execute_kwargs) 134 | with open(self.file_path) as f: 135 | nb = nbformat.read(f, as_version=self.nbformat_version) 136 | 137 | try: 138 | executor.preprocess(nb, {"metadata": {"path": self.path}}) 139 | except CellExecutionError: 140 | logger.error(f"Notebook '{self.filename}' errored ❌") 141 | raise 142 | 143 | run_time = time.time() - t0 144 | logger.info( 145 | f"Finished running notebook '{self.filename}' " 146 | f"({run_time:.2f} seconds) ✅" 147 | ) 148 | 149 | logger.debug(f"Writing executed notebook to file {self.exec_path}") 150 | with open(self.exec_path, "w") as f: 151 | nbformat.write(nb, f) 152 | 153 | return self.exec_path 154 | 155 | def convert(self): 156 | """Convert the executed notebook to a static HTML file.""" 157 | self.execute() 158 | 159 | if os.path.exists(self.html_path) and not self.overwrite: 160 | logger.debug( 161 | "Rendered notebook page already exists at " 162 | f"{self.html_path}. Use overwrite=True to " 163 | "overwrite." 164 | ) 165 | return self.html_path 166 | 167 | # Initialize the resources dict: 168 | resources = {} 169 | resources["config_dir"] = "" # we don't need to specify config 170 | resources["unique_key"] = self.filename 171 | 172 | # path to store extra files, like plots generated 173 | resources["output_files_dir"] = "nboutput" 174 | 175 | if self._config.github_repo_url is not None: 176 | # Path of the notebook relative to the root of the repository 177 | repo_path = ( 178 | PurePosixPath(self._config.github_repo_path) 179 | .joinpath(self._repo_path) 180 | .as_posix() 181 | ) 182 | 183 | # URL to open the notebook in the Binder editor 184 | qs = urlencode({"labpath": repo_path}, doseq=True) 185 | editor_url = ( 186 | f"https://mybinder.org/v2/gh/{self._config.github_owner}/{self._config.github_repo}" 187 | f"/{self._config.github_repo_branch}?{qs}" 188 | ) 189 | resources["learn_astropy_editor_url"] = editor_url 190 | 191 | # URL to open the GitHub source view for the notebook 192 | resources["learn_astropy_source_url"] = ( 193 | f"https://github.com/{self._config.github_owner}/{self._config.github_repo}/blob/" 194 | f"{self._config.github_repo_branch}/" 195 | f"{repo_path}" 196 | ) 197 | 198 | # Relative path to the executed notebook published to the site 199 | # alongside the HTML file. 200 | resources["learn_astropy_ipynb_download_url"] = ( 201 | PurePosixPath(self.html_path).with_suffix(".ipynb").name 202 | ) 203 | 204 | # Exports the notebook to HTML 205 | logger.debug("Exporting notebook to HTML...") 206 | exporter = LearnAstropyHtmlExporter( 207 | config=self.converter_config, **self.convert_kwargs 208 | ) 209 | output, resources = exporter.from_filename(self.exec_path, resources=resources) 210 | 211 | # Write the output HTML file 212 | writer = FilesWriter(build_directory=os.path.dirname(self.html_path)) 213 | return writer.write(output, resources, notebook_name=self.basename) 214 | -------------------------------------------------------------------------------- /nbcollection/themes/__init__.py: -------------------------------------------------------------------------------- 1 | """nbconvert themes for styling exported notebooks.""" 2 | -------------------------------------------------------------------------------- /nbcollection/themes/learnastropy/__init__.py: -------------------------------------------------------------------------------- 1 | """Learn Astropy theme for nbconvert.""" 2 | -------------------------------------------------------------------------------- /nbcollection/themes/learnastropy/html.py: -------------------------------------------------------------------------------- 1 | """HTML Exporter for Learn Astropy Tutorials.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | from nbconvert.exporters.html import HTMLExporter 9 | from traitlets.config import Config 10 | 11 | from .tocpreprocessor import TocPreprocessor 12 | 13 | 14 | class LearnAstropyHtmlExporter(HTMLExporter): 15 | """HTML exporter for Learn Astropy HTML notebooks.""" 16 | 17 | export_from_notebook = "Learn Astropy HTML" 18 | 19 | def __init__(self, *args: Any, **kwargs: Any) -> None: 20 | # Require the TocPreprocessor to populate the table of contents 21 | # in the resources 22 | if "config" not in kwargs: 23 | kwargs["config"] = Config() 24 | kwargs["config"].HTMLExporter.preprocessors = [TocPreprocessor] 25 | 26 | super().__init__(*args, **kwargs) 27 | 28 | # Add the default template to the search path 29 | self.extra_template_basedirs.append(self._template_name_default()) 30 | 31 | def _template_name_default(self) -> str: 32 | """Select built-in HTML theme as the default. 33 | 34 | Overrides `HTMLExporter._template_name_default`. 35 | """ 36 | return str(Path(__file__).parent.joinpath("templates").joinpath("html")) 37 | 38 | def _init_resources(self, resources: dict[str, Any]) -> dict[str, Any]: 39 | """Add additional metadata to the Jinja context via the resources dictionary.""" 40 | # This is an exporter hook that we can use in the future to add 41 | # additional metadata to the Jinja context. 42 | return super()._init_resources(resources) 43 | -------------------------------------------------------------------------------- /nbcollection/themes/learnastropy/templates/html/astropy-footer.html.j2: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /nbcollection/themes/learnastropy/templates/html/astropy-header.html.j2: -------------------------------------------------------------------------------- 1 |
2 |

3 | Learn.Astropy 4 | / 5 | Tutorials 6 |

7 | 18 |
19 | -------------------------------------------------------------------------------- /nbcollection/themes/learnastropy/templates/html/astropy-sidebar.html.j2: -------------------------------------------------------------------------------- 1 | {% macro toc_sections(sections) -%} 2 | 3 |
    4 | {% for section in sections %} 5 |
  1. {{ section.title }} 6 | 7 | {% if section.children|length > 0 %} 8 | {{ toc_sections(section.children) }} 9 | {% endif %} 10 | 11 |
  2. 12 | 13 | {% endfor %} 14 |
15 | 16 | {%- endmacro %} 17 | 18 | 31 | -------------------------------------------------------------------------------- /nbcollection/themes/learnastropy/templates/html/astropytutorial.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --astropy-primary-color: #fa743b; 3 | 4 | /* Set base font-size that all rems become relative to. */ 5 | font-size: 1.2rem; 6 | } 7 | 8 | html { 9 | font-family: 10 | system-ui, 11 | -apple-system, 12 | BlinkMacSystemFont, 13 | "Segoe UI", 14 | Roboto, 15 | Oxygen, 16 | Ubuntu, 17 | Cantarell, 18 | "Open Sans", 19 | "Helvetica Neue", 20 | sans-serif; 21 | font-size: 1rem; 22 | line-height: 1.5; 23 | color: #000; 24 | } 25 | 26 | body { 27 | display: grid; 28 | grid-template-columns: 18rem 1fr; 29 | grid-template-rows: auto 1fr auto; 30 | } 31 | 32 | /* Set base color tokens for the dark theme. */ 33 | body[jp-theme-light="false"] { 34 | color: #fff; 35 | } 36 | 37 | body.jp-Notebook { 38 | padding: 0; 39 | margin: 0; 40 | } 41 | 42 | header { 43 | grid-area: 1 / 1 / 2 / 3; 44 | padding: 0.5rem; 45 | background-color: #000; 46 | color: #fff; 47 | display: flex; 48 | flex-direction: row; 49 | justify-content: space-between; 50 | align-items: baseline; 51 | } 52 | 53 | main { 54 | grid-area: 2 / 2 / 3 / 3; 55 | max-width: 60rem; 56 | } 57 | 58 | .at-notebook-sidebar { 59 | grid-area: 2 / 1 / 3 / 2; 60 | padding: 0.5rem; 61 | } 62 | 63 | .at-notebook-sidebar__content { 64 | position: sticky; 65 | top: 1rem; 66 | max-height: 100vh; 67 | overflow-y: auto; 68 | } 69 | 70 | .at-tutorial-footer { 71 | grid-area: 3 / 1 / 4 / 3; 72 | padding: 0.5rem; 73 | } 74 | 75 | .at-logotext { 76 | font-size: 1.2rem; 77 | } 78 | 79 | .at-logotext a:hover { 80 | color: var(--astropy-primary-color); 81 | } 82 | 83 | .at-logotext__primary { 84 | font-weight: bold; 85 | } 86 | 87 | .at-logotext__divider { 88 | opacity: 50%; 89 | color: var(--astropy-primary-color); 90 | } 91 | 92 | .at-header-nav { 93 | display: flex; 94 | font-weight: bold; 95 | } 96 | 97 | .at-header-nav a { 98 | margin-left: 1.5rem; 99 | } 100 | 101 | .at-header-nav a:hover { 102 | color: var(--astropy-primary-color); 103 | text-decoration: underline; 104 | } 105 | 106 | .at-tutorial-footer { 107 | margin-top: 2rem; 108 | border-top: 1px solid var(--astropy-primary-color); 109 | width: 100%; 110 | } 111 | 112 | .at-outline { 113 | list-style-type: none; 114 | padding-left: 1rem; 115 | } 116 | 117 | .at-notebook-sidebar-section > .at-outline { 118 | /* Make the root level section not have any indentation */ 119 | padding-left: 0; 120 | } 121 | -------------------------------------------------------------------------------- /nbcollection/themes/learnastropy/templates/html/conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "base_template": "lab", 3 | "mimetypes": { 4 | "text/html": true 5 | }, 6 | "preprocessors": { 7 | "900-extract-outputs": { 8 | "type": "nbconvert.preprocessors.ExtractOutputPreprocessor", 9 | "enabled": true 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /nbcollection/themes/learnastropy/templates/html/index.html.j2: -------------------------------------------------------------------------------- 1 | {%- extends 'base.html.j2' -%} 2 | {% from 'mathjax.html.j2' import mathjax %} 3 | {% from 'jupyter_widgets.html.j2' import jupyter_widgets %} 4 | 5 | {%- block header -%} 6 | 7 | 8 | 9 | {%- block html_head -%} 10 | 11 | 12 | {% set nb_title = nb.metadata.get('title', resources['metadata']['name']) | escape_html_keep_quotes %} 13 | Custom template: {{nb_title}} 14 | 15 | {%- block html_head_js -%} 16 | {%- block html_head_js_requirejs -%} 17 | 18 | {%- endblock html_head_js_requirejs -%} 19 | {%- endblock html_head_js -%} 20 | 21 | {% block jupyter_widgets %} 22 | {%- if "widgets" in nb.metadata -%} 23 | {{ jupyter_widgets(resources.jupyter_widgets_base_url, resources.html_manager_semver_range, resources.widget_renderer_url) }} 24 | {%- endif -%} 25 | {% endblock jupyter_widgets %} 26 | 27 | {% block extra_css %} 28 | {% endblock extra_css %} 29 | 30 | {% for css in resources.inlining.css -%} 31 | 34 | {% endfor %} 35 | 36 | {% block notebook_css %} 37 | {{ resources.include_css("static/index.css") }} 38 | {% if resources.theme == 'dark' %} 39 | {{ resources.include_css("static/theme-dark.css") }} 40 | {% elif resources.theme == 'light' %} 41 | {{ resources.include_css("static/theme-light.css") }} 42 | {% else %} 43 | {{ resources.include_lab_theme(resources.theme) }} 44 | {% endif %} 45 | {{ resources.include_css("astropytutorial.css") }} 46 | 142 | 143 | {% endblock notebook_css %} 144 | 145 | {%- block html_head_js_mathjax -%} 146 | {{ mathjax(resources.mathjax_url) }} 147 | {%- endblock html_head_js_mathjax -%} 148 | 149 | {%- block html_head_css -%} 150 | {%- endblock html_head_css -%} 151 | 152 | {%- endblock html_head -%} 153 | 154 | {%- endblock header -%} 155 | 156 | {%- block body_header -%} 157 | {% if resources.theme == 'dark' %} 158 | 159 | {% else %} 160 | 161 | {% endif %} 162 | {%- endblock body_header -%} 163 | 164 | {% block body_loop %} 165 | 166 | {% include "astropy-header.html.j2" %} 167 | 168 | {% include "astropy-sidebar.html.j2" %} 169 | 170 |
171 | {{ super() }} 172 |
173 | 174 | 175 | {% endblock body_loop %} 176 | 177 | {% block body_footer %} 178 | 179 | {% endblock body_footer %} 180 | 181 | {% block footer %} 182 | {% block footer_js %} 183 | {% endblock footer_js %} 184 | {{ super() }} 185 | 186 | {% endblock footer %} 187 | -------------------------------------------------------------------------------- /nbcollection/themes/learnastropy/tocpreprocessor.py: -------------------------------------------------------------------------------- 1 | """nbconvert preprocessor that extracts the document outline (TOC).""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Collection, Iterator 6 | from dataclasses import dataclass 7 | from typing import TYPE_CHECKING, Any 8 | 9 | from bs4 import BeautifulSoup 10 | from markdown_it import MarkdownIt 11 | from markdown_it.tree import SyntaxTreeNode 12 | from nbconvert.filters import add_anchor 13 | from nbconvert.preprocessors import Preprocessor 14 | 15 | if TYPE_CHECKING: 16 | from nbformat import NotebookNode 17 | 18 | 19 | class TocPreprocessor(Preprocessor): 20 | """An nbconvert preprocessor that extracts the document outline (TOC). 21 | 22 | The TOC is available under the key `learn_astropy_toc` in the resources 23 | dictionary. It is a list of dictionaries, each of which represents a 24 | section in the document. Each dictionary has the following keys: 25 | 26 | - `title`: The plain text title of the section. 27 | - `children`: A list of dictionaries representing the children of the 28 | section. 29 | - `href`: The href of the section. E.g. ``#Section-title``. 30 | - `level`: The heading level of the section. 31 | """ 32 | 33 | def preprocess( 34 | self, nb: NotebookNode, resources: dict[str, Any] 35 | ) -> tuple[NotebookNode, dict[str, Any]]: 36 | """Extract the document outline into the resources for the HTML exporter.""" 37 | markdown = self._extract_markdown(nb) 38 | md = MarkdownIt() 39 | md_tokens = md.parse(markdown) 40 | token_tree = SyntaxTreeNode(md_tokens) 41 | sections = SectionChildren([]) 42 | heading_nodes = [n for n in token_tree.children if n.type == "heading"] 43 | for node in heading_nodes: 44 | section = Section.create_from_node(node) 45 | sections.insert_section(section) 46 | 47 | # Add the document outine, including only the sections, but not 48 | # the h1 title 49 | resources["learn_astropy_toc"] = sections[0].children.export() 50 | 51 | return nb, resources 52 | 53 | def _extract_markdown(self, nb: NotebookNode) -> str: 54 | """Extract the markdown content from the notebook.""" 55 | markdown_cells = [c.source for c in nb.cells if c.cell_type == "markdown"] 56 | return "\n\n".join(markdown_cells) 57 | 58 | 59 | class SectionChildren(Collection): 60 | """A collection of Section objects.""" 61 | 62 | def __init__(self, sections: list[Section]) -> None: 63 | self._sections = sections 64 | 65 | def __contains__(self, x: object) -> bool: 66 | return x in self._sections 67 | 68 | def __iter__(self) -> Iterator[Section]: 69 | return iter(self._sections) 70 | 71 | def __len__(self) -> int: 72 | return len(self._sections) 73 | 74 | def __getitem__(self, index: int) -> Section: 75 | return self._sections[index] 76 | 77 | def __repr__(self) -> str: 78 | return f"SectionChildren({self._sections})" 79 | 80 | def insert_section(self, section: Section) -> None: 81 | """Insert a section at the correct level of hierarchy.""" 82 | if len(self) == 0: 83 | # No children, so append 84 | self._sections.append(section) 85 | elif self._sections[-1].level == section.level: 86 | # Same level as direct children, so append 87 | self._sections.append(section) 88 | else: 89 | # Not the same level as direct children, so insert into last child 90 | self._sections[-1].children.insert_section(section) 91 | 92 | def export(self) -> list[dict]: 93 | """Export the section hierarchy as a list of dictionaries.""" 94 | return [s.as_dict() for s in self._sections] 95 | 96 | 97 | @dataclass 98 | class Section: 99 | """A section in the document and its children.""" 100 | 101 | title: str 102 | """The plain text title of the section.""" 103 | 104 | children: SectionChildren 105 | """The children of the section.""" 106 | 107 | href: str = "#" 108 | """The href of the section.""" 109 | 110 | level: int = 0 111 | """The heading level of the section.""" 112 | 113 | @classmethod 114 | def create_from_node(cls, node: SyntaxTreeNode) -> Section: 115 | """Create a section from a Markdown heading node.""" 116 | # The content of the heading can contain inline formatting. This is 117 | # a basic way to strip out code, bold, and italics. We need to 118 | # revisit this to strip out links. 119 | title = node.children[0].content 120 | title = title.replace("`", "") 121 | title = title.replace("*", "") 122 | title = title.replace("_", "") 123 | 124 | level = int(node.tag.lstrip("h")) 125 | 126 | # Use nbconvert's own add_anchor function to compute the anchor 127 | # href. Unfortunately this relies on a roundtrip through HTML. The 128 | # function that works directly on the title text isn't a pubilc API. 129 | header_html = f"{title}" 130 | header_html = add_anchor(header_html, anchor_link_text="#") 131 | header_soup = BeautifulSoup(header_html, "html.parser") 132 | try: 133 | href = header_soup.find("a")["href"] 134 | except KeyError: 135 | href = "#" 136 | 137 | return cls(title=title, children=SectionChildren([]), href=href, level=level) 138 | 139 | def as_dict(self) -> dict[str, Any]: 140 | """Convert the section to a dictionary.""" 141 | return { 142 | "title": self.title, 143 | "children": [c.as_dict() for c in self.children], 144 | "href": self.href, 145 | "level": self.level, 146 | } 147 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nbcollection" 3 | dynamic = [ 4 | "version" 5 | ] 6 | description = "Execute and convert collections of Jupyter notebooks to static HTML sites." 7 | license = {file = "LICENSE"} 8 | readme = "README.md" 9 | authors = [ 10 | { name = "The Astropy Developers", email = "astropy.team@gmail.com" }, 11 | { name = "Adrian Price-Whelan", email = "adrianmpw@gmail.com" }, 12 | { name = "Erik Tollerud" } 13 | ] 14 | requires-python = ">=3.9" 15 | classifiers = [ 16 | "Intended Audience :: Science/Research", 17 | "License :: OSI Approved :: BSD-3-Clause", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python :: 3", 20 | "Topic :: Scientific/Engineering :: Astronomy", 21 | "Topic :: Scientific/Engineering :: Physics", 22 | ] 23 | keywords = [ 24 | "astronomy", 25 | "jupyter", 26 | "notebooks", 27 | "tutorials", 28 | ] 29 | dependencies = [ 30 | "beautifulsoup4", 31 | "jupyter-client", 32 | "markdown-it-py", 33 | "nbconvert", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | test = [ 38 | "pytest>=7.0", 39 | "mypy", 40 | "notebook", 41 | ] 42 | 43 | [project.urls] 44 | repository = "https://github.com/astropy/nbcollection" 45 | 46 | [project.scripts] 47 | nbcollection = "nbcollection.__main__:main" 48 | 49 | [project.entry-points."nbconvert.exporters"] 50 | learn-astropy = "learnastropytutorialtheme.html:LearnAstropyExporter" 51 | 52 | [build-system] 53 | requires = [ 54 | "setuptools", 55 | "setuptools_scm>=6.2", 56 | ] 57 | build-backend = "setuptools.build_meta" 58 | 59 | [tool.setuptools] 60 | include-package-data = true 61 | 62 | [tool.setuptools.packages.find] 63 | include = ["nbcollection*"] 64 | 65 | [tool.black] 66 | line-length = 88 67 | 68 | [tool.mypy] 69 | disallow_untyped_defs = false # for partial adoption of type hints 70 | disallow_incomplete_defs = false # for partial adoption of type hints 71 | ignore_missing_imports = true 72 | strict_equality = true 73 | warn_redundant_casts = true 74 | warn_unreachable = true 75 | warn_unused_ignores = true 76 | 77 | [tool.ruff] 78 | target-version = "py39" 79 | line-length = 88 80 | select = ["ALL"] 81 | ignore = [ # NOTE: non-permeanent exclusions should be added to `.ruff.toml` instead. 82 | # Don't run type checking yet 83 | "ANN", 84 | 85 | # flake8-builtins (A) : shadowing a Python built-in. 86 | # New ones should be avoided and is up to maintainers to enforce. 87 | "A00", 88 | 89 | # flake8-bugbear (B) 90 | "B008", # FunctionCallArgumentDefault 91 | 92 | # flake8-commas (COM) 93 | "COM812", # TrailingCommaMissing 94 | "COM819", # TrailingCommaProhibited 95 | 96 | # pydocstyle (D) 97 | # Missing Docstrings 98 | "D102", # Missing docstring in public method. Don't check b/c docstring inheritance. 99 | "D105", # Missing docstring in magic method. Don't check b/c class docstring. 100 | # Whitespace Issues 101 | "D200", # FitsOnOneLine 102 | # Docstring Content Issues 103 | "D410", # BlankLineAfterSection. Using D412 instead. 104 | "D400", # EndsInPeriod. NOTE: might want to revisit this. 105 | 106 | # pycodestyle (E, W) 107 | "E711", # NoneComparison (see unfixable) 108 | "E741", # AmbiguousVariableName. Physics variables are often poor code variables 109 | 110 | # flake8-fixme (FIX) 111 | "FIX002", # Line contains TODO | notes for improvements are OK iff the code works 112 | 113 | # pep8-naming (N) 114 | "N803", # invalid-argument-name. Physics variables are often poor code variables 115 | "N806", # non-lowercase-variable-in-function. Physics variables are often poor code variables 116 | 117 | # Disable conversion to pathlib initially 118 | "PTH", 119 | 120 | # pandas-vet (PD) 121 | "PD", 122 | 123 | # flake8-self (SLF) 124 | "SLF001", # private member access 125 | 126 | # flake8-todos (TD) 127 | "TD002", # Missing author in TODO 128 | "TD003", # Missing issue link in TODO 129 | 130 | # Ruff-specific rules (RUF) 131 | "RUF005", # unpack-instead-of-concatenating-to-collection-literal -- it's not clearly faster. 132 | ] 133 | 134 | [tool.ruff.extend-per-file-ignores] 135 | "test_*.py" = [ 136 | "B018", # UselessExpression 137 | "D", # pydocstyle 138 | "E402", # Module level import not at top of file 139 | "PGH001", # No builtin eval() allowed 140 | "S101", # Use of assert detected 141 | "T203", # allow pprint in tests for debugging 142 | ] 143 | "nbcollection/commands/argparse_helpers.py" = [ 144 | "PLR2004", # Used to set logger level 145 | ] 146 | "nbcollection/converter.py" = [ 147 | "C901", # __init__ needs to be simplified 148 | "PLR0912", # __init__ needs to be simplified 149 | "PLR0913", # __init__ needs to be simplified 150 | "PERF203", # catching exceptions in a loop 151 | "BLE001", # catching Exception is necessary right now (?) 152 | ] 153 | "nbcollection/notebook.py" = [ 154 | "PLR0913", # too many arguments to __init__ 155 | ] 156 | 157 | [tool.ruff.isort] 158 | known-first-party = ["nbcollection", "tests"] 159 | split-on-trailing-comma = false 160 | 161 | # These are too useful as attributes or methods to allow the conflict with the 162 | # built-in to rule out their use. 163 | [tool.ruff.flake8-builtins] 164 | builtins-ignorelist = [ 165 | "all", 166 | "any", 167 | "dict", 168 | "help", 169 | "id", 170 | "list", 171 | "open", 172 | "type", 173 | ] 174 | 175 | [tool.ruff.flake8-pytest-style] 176 | fixture-parentheses = false 177 | mark-parentheses = false 178 | 179 | [tool.ruff.pydocstyle] 180 | convention = "numpy" 181 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Nbcollection tests.""" 2 | -------------------------------------------------------------------------------- /tests/data/default.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Notebook Index 6 | 7 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /tests/data/exception-should-fail.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "This notebook should raise an exception:" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "class Thing:\n", 17 | " a = 1\n", 18 | " b = 'cat'" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": {}, 25 | "outputs": [], 26 | "source": [ 27 | "thing = Thing()\n", 28 | "thing.c" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": null, 34 | "metadata": {}, 35 | "outputs": [], 36 | "source": [] 37 | } 38 | ], 39 | "metadata": { 40 | "language_info": { 41 | "codemirror_mode": { 42 | "name": "ipython", 43 | "version": 3 44 | }, 45 | "file_extension": ".py", 46 | "mimetype": "text/x-python", 47 | "name": "python", 48 | "nbconvert_exporter": "python", 49 | "pygments_lexer": "ipython3", 50 | "version": "3.7.5" 51 | }, 52 | "toc": { 53 | "base_numbering": 1, 54 | "nav_menu": {}, 55 | "number_sections": true, 56 | "sideBar": true, 57 | "skip_h1_title": false, 58 | "title_cell": "Table of Contents", 59 | "title_sidebar": "Contents", 60 | "toc_cell": false, 61 | "toc_position": {}, 62 | "toc_section_display": true, 63 | "toc_window_display": false 64 | } 65 | }, 66 | "nbformat": 4, 67 | "nbformat_minor": 2 68 | } 69 | -------------------------------------------------------------------------------- /tests/data/exception-should-pass.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "This notebook should raise an exception, but the metadata for the failing cell has \"raises-exception\" and nbconvert should therefore succeed:" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "class Thing:\n", 17 | " a = 1\n", 18 | " b = 'cat'" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": { 25 | "tags": [ 26 | "raises-exception" 27 | ] 28 | }, 29 | "outputs": [], 30 | "source": [ 31 | "thing = Thing()\n", 32 | "thing.c" 33 | ] 34 | }, 35 | { 36 | "cell_type": "code", 37 | "execution_count": null, 38 | "metadata": {}, 39 | "outputs": [], 40 | "source": [] 41 | } 42 | ], 43 | "metadata": { 44 | "celltoolbar": "Tags", 45 | "language_info": { 46 | "codemirror_mode": { 47 | "name": "ipython", 48 | "version": 3 49 | }, 50 | "file_extension": ".py", 51 | "mimetype": "text/x-python", 52 | "name": "python", 53 | "nbconvert_exporter": "python", 54 | "pygments_lexer": "ipython3", 55 | "version": "3.7.5" 56 | }, 57 | "toc": { 58 | "base_numbering": 1, 59 | "nav_menu": {}, 60 | "number_sections": true, 61 | "sideBar": true, 62 | "skip_h1_title": false, 63 | "title_cell": "Table of Contents", 64 | "title_sidebar": "Contents", 65 | "toc_cell": false, 66 | "toc_position": {}, 67 | "toc_section_display": true, 68 | "toc_window_display": false 69 | } 70 | }, 71 | "nbformat": 4, 72 | "nbformat_minor": 2 73 | } 74 | -------------------------------------------------------------------------------- /tests/data/my_notebooks/notebook1.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# My Notebook 1" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import os" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "print(\"I am notebook 1\")" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": null, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [] 34 | } 35 | ], 36 | "metadata": { 37 | "language_info": { 38 | "codemirror_mode": { 39 | "name": "ipython", 40 | "version": 3 41 | }, 42 | "file_extension": ".py", 43 | "mimetype": "text/x-python", 44 | "name": "python", 45 | "nbconvert_exporter": "python", 46 | "pygments_lexer": "ipython3", 47 | "version": "3.7.5" 48 | }, 49 | "toc": { 50 | "base_numbering": 1, 51 | "nav_menu": {}, 52 | "number_sections": true, 53 | "sideBar": true, 54 | "skip_h1_title": false, 55 | "title_cell": "Table of Contents", 56 | "title_sidebar": "Contents", 57 | "toc_cell": false, 58 | "toc_position": {}, 59 | "toc_section_display": true, 60 | "toc_window_display": false 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 2 65 | } 66 | -------------------------------------------------------------------------------- /tests/data/my_notebooks/sub_path1/notebook2.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# My Notebook 2" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import os" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "print(\"I am notebook 2\")" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": null, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [] 34 | } 35 | ], 36 | "metadata": { 37 | "language_info": { 38 | "codemirror_mode": { 39 | "name": "ipython", 40 | "version": 3 41 | }, 42 | "file_extension": ".py", 43 | "mimetype": "text/x-python", 44 | "name": "python", 45 | "nbconvert_exporter": "python", 46 | "pygments_lexer": "ipython3", 47 | "version": "3.7.5" 48 | }, 49 | "toc": { 50 | "base_numbering": 1, 51 | "nav_menu": {}, 52 | "number_sections": true, 53 | "sideBar": true, 54 | "skip_h1_title": false, 55 | "title_cell": "Table of Contents", 56 | "title_sidebar": "Contents", 57 | "toc_cell": false, 58 | "toc_position": {}, 59 | "toc_section_display": true, 60 | "toc_window_display": false 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 2 65 | } 66 | -------------------------------------------------------------------------------- /tests/data/my_notebooks/sub_path1/notebook3.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# My Notebook 3" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import os" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "print(\"I am notebook 3\")" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": null, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [] 34 | } 35 | ], 36 | "metadata": { 37 | "language_info": { 38 | "codemirror_mode": { 39 | "name": "ipython", 40 | "version": 3 41 | }, 42 | "file_extension": ".py", 43 | "mimetype": "text/x-python", 44 | "name": "python", 45 | "nbconvert_exporter": "python", 46 | "pygments_lexer": "ipython3", 47 | "version": "3.7.5" 48 | }, 49 | "toc": { 50 | "base_numbering": 1, 51 | "nav_menu": {}, 52 | "number_sections": true, 53 | "sideBar": true, 54 | "skip_h1_title": false, 55 | "title_cell": "Table of Contents", 56 | "title_sidebar": "Contents", 57 | "toc_cell": false, 58 | "toc_position": {}, 59 | "toc_section_display": true, 60 | "toc_window_display": false 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 2 65 | } 66 | -------------------------------------------------------------------------------- /tests/data/nb_test1/notebook1.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Notebook 1.1" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import os" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "print(\"I am notebook 1\")" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": null, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [] 34 | } 35 | ], 36 | "metadata": { 37 | "language_info": { 38 | "codemirror_mode": { 39 | "name": "ipython", 40 | "version": 3 41 | }, 42 | "file_extension": ".py", 43 | "mimetype": "text/x-python", 44 | "name": "python", 45 | "nbconvert_exporter": "python", 46 | "pygments_lexer": "ipython3", 47 | "version": "3.7.5" 48 | }, 49 | "toc": { 50 | "base_numbering": 1, 51 | "nav_menu": {}, 52 | "number_sections": true, 53 | "sideBar": true, 54 | "skip_h1_title": false, 55 | "title_cell": "Table of Contents", 56 | "title_sidebar": "Contents", 57 | "toc_cell": false, 58 | "toc_position": {}, 59 | "toc_section_display": true, 60 | "toc_window_display": false 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 2 65 | } 66 | -------------------------------------------------------------------------------- /tests/data/nb_test2/notebook1.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Notebook 2.1" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import os" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "print(\"I am notebook 1\")" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": null, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [] 34 | } 35 | ], 36 | "metadata": { 37 | "language_info": { 38 | "codemirror_mode": { 39 | "name": "ipython", 40 | "version": 3 41 | }, 42 | "file_extension": ".py", 43 | "mimetype": "text/x-python", 44 | "name": "python", 45 | "nbconvert_exporter": "python", 46 | "pygments_lexer": "ipython3", 47 | "version": "3.7.5" 48 | }, 49 | "toc": { 50 | "base_numbering": 1, 51 | "nav_menu": {}, 52 | "number_sections": true, 53 | "sideBar": true, 54 | "skip_h1_title": false, 55 | "title_cell": "Table of Contents", 56 | "title_sidebar": "Contents", 57 | "toc_cell": false, 58 | "toc_position": {}, 59 | "toc_section_display": true, 60 | "toc_window_display": false 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 2 65 | } 66 | -------------------------------------------------------------------------------- /tests/data/nb_test2/notebook2.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Notebook 2.2" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import os" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "print(\"I am notebook 2\")" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": null, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [] 34 | } 35 | ], 36 | "metadata": { 37 | "language_info": { 38 | "codemirror_mode": { 39 | "name": "ipython", 40 | "version": 3 41 | }, 42 | "file_extension": ".py", 43 | "mimetype": "text/x-python", 44 | "name": "python", 45 | "nbconvert_exporter": "python", 46 | "pygments_lexer": "ipython3", 47 | "version": "3.7.5" 48 | }, 49 | "toc": { 50 | "base_numbering": 1, 51 | "nav_menu": {}, 52 | "number_sections": true, 53 | "sideBar": true, 54 | "skip_h1_title": false, 55 | "title_cell": "Table of Contents", 56 | "title_sidebar": "Contents", 57 | "toc_cell": false, 58 | "toc_position": {}, 59 | "toc_section_display": true, 60 | "toc_window_display": false 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 2 65 | } 66 | -------------------------------------------------------------------------------- /tests/data/nb_test3/nb1/notebook1.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Notebook 3.1" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import os" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "print(\"I am notebook 1\")" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": null, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [] 34 | } 35 | ], 36 | "metadata": { 37 | "language_info": { 38 | "codemirror_mode": { 39 | "name": "ipython", 40 | "version": 3 41 | }, 42 | "file_extension": ".py", 43 | "mimetype": "text/x-python", 44 | "name": "python", 45 | "nbconvert_exporter": "python", 46 | "pygments_lexer": "ipython3", 47 | "version": "3.7.5" 48 | }, 49 | "toc": { 50 | "base_numbering": 1, 51 | "nav_menu": {}, 52 | "number_sections": true, 53 | "sideBar": true, 54 | "skip_h1_title": false, 55 | "title_cell": "Table of Contents", 56 | "title_sidebar": "Contents", 57 | "toc_cell": false, 58 | "toc_position": {}, 59 | "toc_section_display": true, 60 | "toc_window_display": false 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 2 65 | } 66 | -------------------------------------------------------------------------------- /tests/data/nb_test3/nb2/notebook2.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Notebook 3.2" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import os" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "print(\"I am notebook 2\")" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": null, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [] 34 | } 35 | ], 36 | "metadata": { 37 | "language_info": { 38 | "codemirror_mode": { 39 | "name": "ipython", 40 | "version": 3 41 | }, 42 | "file_extension": ".py", 43 | "mimetype": "text/x-python", 44 | "name": "python", 45 | "nbconvert_exporter": "python", 46 | "pygments_lexer": "ipython3", 47 | "version": "3.7.5" 48 | }, 49 | "toc": { 50 | "base_numbering": 1, 51 | "nav_menu": {}, 52 | "number_sections": true, 53 | "sideBar": true, 54 | "skip_h1_title": false, 55 | "title_cell": "Table of Contents", 56 | "title_sidebar": "Contents", 57 | "toc_cell": false, 58 | "toc_position": {}, 59 | "toc_section_display": true, 60 | "toc_window_display": false 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 2 65 | } 66 | -------------------------------------------------------------------------------- /tests/support/__init__.py: -------------------------------------------------------------------------------- 1 | """Support modules for tests.""" 2 | -------------------------------------------------------------------------------- /tests/support/themes.py: -------------------------------------------------------------------------------- 1 | """Support code for testing themes.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | from shutil import rmtree 7 | from typing import Any 8 | 9 | 10 | def write_conversion(*, base_dir: str, content: str, resources: dict[str, Any]) -> None: 11 | """Write an nbconvert conversion out to the filesystem, in the _build/ directory.""" 12 | build_dir = Path(__file__).parent.joinpath("../../_build") 13 | build_dir.mkdir(exist_ok=True) 14 | 15 | instance_dir = build_dir.joinpath(base_dir) 16 | if instance_dir.is_dir(): 17 | rmtree(instance_dir) 18 | instance_dir.mkdir(parents=True) 19 | 20 | content_path = instance_dir.joinpath( 21 | f"{resources['metadata']['name']}{resources['output_extension']}" 22 | ) 23 | content_path.write_text(content) 24 | 25 | for name, file_content in resources["outputs"].items(): 26 | p = instance_dir.joinpath(name) 27 | p.write_bytes(file_content) 28 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | from bs4 import BeautifulSoup 6 | 7 | from nbcollection.__main__ import main 8 | 9 | 10 | @pytest.mark.parametrize("command", ["execute", "convert"]) 11 | def test_default(tmp_path, command): 12 | test_root_path = os.path.dirname(__file__) 13 | 14 | nb_name = "notebook1.ipynb" 15 | nb_path = os.path.join(test_root_path, f"data/nb_test1/{nb_name}") 16 | nb_root_path = os.path.split(nb_path)[0] 17 | counter = 0 18 | 19 | # Default behavior, one notebook input, no specified build path 20 | _ = main(["nbcollection", command, nb_path]) 21 | assert "_build" in os.listdir(nb_root_path) 22 | assert nb_name in os.listdir(os.path.join(nb_root_path, "_build")) 23 | 24 | # Default behavior, one notebook input, specified build path 25 | build_path = tmp_path / f"test_{command}_{counter}" 26 | counter += 1 27 | build_path.mkdir() 28 | _ = main(["nbcollection", command, nb_path, f"--build-path={build_path!s}"]) 29 | assert "_build" in os.listdir(str(build_path)) 30 | assert nb_name in os.listdir(os.path.join(build_path, "_build")) 31 | 32 | # Default behavior, one notebook path, no specified build path 33 | _ = main(["nbcollection", command, nb_root_path]) 34 | assert "_build" in os.listdir(os.path.join(nb_root_path, "..")) 35 | assert nb_name in os.listdir(os.path.join(nb_root_path, "../_build/nb_test1")) 36 | 37 | # Default behavior, one notebook path, specified build path 38 | build_path = tmp_path / f"test_{command}_{counter}" 39 | counter += 1 40 | build_path.mkdir() 41 | _ = main(["nbcollection", command, nb_root_path, f"--build-path={build_path!s}"]) 42 | assert "_build" in os.listdir(str(build_path)) 43 | assert nb_name in os.listdir(os.path.join(build_path, "_build/nb_test1")) 44 | 45 | # Two notebook files, specified build path 46 | nb_path1 = os.path.join(test_root_path, "data/nb_test1/notebook1.ipynb") 47 | nb_path2 = os.path.join(test_root_path, "data/nb_test2/notebook2.ipynb") 48 | build_path = tmp_path / f"test_{command}_{counter}" 49 | counter += 1 50 | build_path.mkdir() 51 | _ = main( 52 | ["nbcollection", command, f"--build-path={build_path!s}", nb_path1, nb_path2] 53 | ) 54 | assert "_build" in os.listdir(str(build_path)) 55 | for nb_name in ["notebook1.ipynb", "notebook2.ipynb"]: 56 | assert nb_name in os.listdir(os.path.join(build_path, "_build")) 57 | 58 | 59 | @pytest.mark.parametrize("command", ["execute", "convert"]) 60 | def test_flatten(command): 61 | test_root_path = os.path.dirname(__file__) 62 | 63 | nb_root_path = os.path.join(test_root_path, "data/my_notebooks") 64 | 65 | # One notebook path, no specified build path, but flatten the file structure 66 | _ = main(["nbcollection", command, nb_root_path, "--flatten"]) 67 | assert "_build" in os.listdir(os.path.join(nb_root_path, "..")) 68 | for nb_name in ["notebook1", "notebook2", "notebook3"]: 69 | assert f"{nb_name}.ipynb" in os.listdir( 70 | os.path.join(nb_root_path, "../_build/") 71 | ) 72 | 73 | if command == "convert": 74 | assert f"{nb_name}.html" in os.listdir( 75 | os.path.join(nb_root_path, "../_build/") 76 | ) 77 | 78 | 79 | def test_index(tmp_path): 80 | test_root_path = os.path.dirname(__file__) 81 | 82 | nb_root_path = os.path.join(test_root_path, "data/my_notebooks") 83 | index_tpl_path = os.path.join(test_root_path, "data/default.tpl") 84 | 85 | # Make an index file with more complex notebook path structure 86 | build_path = tmp_path / "test_index" 87 | _ = main( 88 | [ 89 | "nbcollection", 90 | "convert", 91 | nb_root_path, 92 | f"--build-path={build_path!s}", 93 | "--make-index", 94 | f"--index-template={index_tpl_path}", 95 | ] 96 | ) 97 | assert "_build" in os.listdir(str(build_path)) 98 | assert "index.html" in os.listdir(str(build_path / "_build")) 99 | 100 | # Flatten the build directory structure and make an index file 101 | _ = main( 102 | [ 103 | "nbcollection", 104 | "convert", 105 | nb_root_path, 106 | "--flatten", 107 | "--make-index", 108 | f"--index-template={index_tpl_path}", 109 | ] 110 | ) 111 | assert "_build" in os.listdir(os.path.join(nb_root_path, "..")) 112 | build_path = os.path.join(nb_root_path, "../_build/") 113 | assert "index.html" in os.listdir(build_path) 114 | 115 | 116 | def test_learn_astropy_theme(tmp_path): 117 | """Build a site and verify that links are populated correctly.""" 118 | source_root = Path(__file__).parent / "data" / "my_notebooks" 119 | build_path = tmp_path / "test_learn_astropy_theme" 120 | 121 | _ = main( 122 | [ 123 | "nbcollection", 124 | "convert", 125 | str(source_root), 126 | f"--build-path={build_path!s}", 127 | "--flatten", 128 | "--github-url", 129 | "https://github.com/astropy/astropy-tutorials", 130 | "--github-path", 131 | "tutorials", 132 | "--github-branch", 133 | "main", 134 | ] 135 | ) 136 | 137 | # Test links 138 | html_path = build_path / "_build" / "notebook1.html" 139 | soup = BeautifulSoup(html_path.read_text(), "html.parser") 140 | header_nav = soup.find("nav", class_="at-header-nav") 141 | binder_link = header_nav.find("a", string="Open in Binder") 142 | assert binder_link.attrs["href"] == ( 143 | "https://mybinder.org/v2/gh/astropy/astropy-tutorials/main" 144 | "?labpath=tutorials%2Fnotebook1.ipynb" 145 | ) 146 | github_link = header_nav.find("a", string="View on GitHub") 147 | assert github_link.attrs["href"] == ( 148 | "https://github.com/astropy/astropy-tutorials/blob/main/tutorials/notebook1.ipynb" 149 | ) 150 | download_link = header_nav.find("a", string="Download this notebook") 151 | assert download_link.attrs["href"] == "notebook1.ipynb" 152 | 153 | 154 | # Too scary... 155 | # def teardown_module(): 156 | # for path in BUILD_PATHS: 157 | # if path is not None and os.path.exists(path): 158 | -------------------------------------------------------------------------------- /tests/themes/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for nbcollection.themes.""" 2 | -------------------------------------------------------------------------------- /tests/themes/data/README.md: -------------------------------------------------------------------------------- 1 | # Testing data for themes 2 | 3 | These are pre-executed Jupyter Notebooks for testing themes without the need to execute. 4 | -------------------------------------------------------------------------------- /tests/themes/learnastropy/__init__.py: -------------------------------------------------------------------------------- 1 | """The Learn Astropy tutorials theme.""" 2 | -------------------------------------------------------------------------------- /tests/themes/learnastropy/test_html.py: -------------------------------------------------------------------------------- 1 | """Test for the Learn Astropy HTML theme.""" 2 | 3 | # mypy: disable-error-code="unreachable" 4 | 5 | from __future__ import annotations 6 | 7 | from pathlib import Path 8 | from pprint import pprint 9 | from typing import Any, cast 10 | 11 | import pytest 12 | from traitlets.config import Config 13 | 14 | from nbcollection.themes.learnastropy.html import LearnAstropyHtmlExporter 15 | from tests.support.themes import write_conversion 16 | 17 | 18 | @pytest.mark.parametrize("theme", ["light", "dark"]) 19 | def test_html_export(theme: str) -> None: 20 | """Integration test for the HTML export with the "color-excess" sample tutorial. 21 | 22 | Output is written to `_build/learnastropy/color-excess/{light|dark}`. 23 | """ 24 | test_notebook = Path(__file__).parent.parent.joinpath("data/color-excess.ipynb") 25 | assert test_notebook.is_file() 26 | 27 | resources = { 28 | "learn_astropy_editor_url": ( 29 | "https://mybinder.org/v2/gh/astropy/astropy-tutorials/" 30 | "main?labpath=tutorials%2Fcolor-excess%2Fcolor-excess.ipynb" 31 | ), 32 | "learn_astropy_source_url": ( 33 | "https://github.com/astropy/astropy-tutorials/blob/main/tutorials/" 34 | "color-excess/color-excess.ipynb" 35 | ), 36 | "learn_astropy_ipynb_download_url": ( 37 | "https://learn.astropy.org/tutorials/color-excess.ipynb" 38 | ), 39 | } 40 | 41 | config = Config() 42 | 43 | exporter = LearnAstropyHtmlExporter(config=config) 44 | exporter.theme = theme 45 | html, resources = exporter.from_filename( 46 | str(test_notebook.resolve()), resources=resources 47 | ) 48 | 49 | assert "learn_astropy_toc" in resources 50 | toc = resources["learn_astropy_toc"] 51 | pprint(toc) 52 | # mypy insists that `toc` is a `str` here, but it's actually a 53 | # `list[dict[str, Any]]`. Doing a cast doesn't work? 54 | toc = cast(list[dict[str, Any]], toc) # type: ignore[assignment] 55 | assert isinstance(toc, list) # type: ignore[unreachable] 56 | assert toc[0]["title"] == "Authors" 57 | assert toc[0]["href"] == "#Authors" 58 | assert toc[1]["title"] == "Learning Goals" 59 | assert toc[1]["href"] == "#Learning-Goals" 60 | assert toc[2]["title"] == "Keywords" 61 | assert toc[2]["href"] == "#Keywords" 62 | assert toc[3]["title"] == "Companion Content" 63 | assert toc[3]["href"] == "#Companion-Content" 64 | assert toc[4]["title"] == "Summary" 65 | assert toc[4]["href"] == "#Summary" 66 | assert toc[5]["title"] == "Introduction" 67 | assert toc[5]["href"] == "#Introduction" 68 | assert toc[6]["title"] == "Examples" 69 | assert toc[6]["href"] == "#Examples" 70 | assert toc[6]["children"][0]["title"] == "Investigate Extinction Models" 71 | assert toc[6]["children"][0]["href"] == "#Investigate-Extinction-Models" 72 | assert toc[6]["children"][1]["title"] == "Deredden a Spectrum" 73 | assert toc[6]["children"][1]["href"] == "#Deredden-a-Spectrum" 74 | assert toc[6]["children"][2]["title"] == "Calculate Color Excess with synphot" 75 | assert toc[6]["children"][2]["href"] == "#Calculate-Color-Excess-with-synphot" 76 | assert toc[7]["title"] == "Exercise" 77 | assert toc[7]["href"] == "#Exercise" 78 | 79 | write_conversion( 80 | base_dir=f"learnastropy/color-excess/{theme}", content=html, resources=resources 81 | ) 82 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint,typing,py 3 | isolated_build = True 4 | 5 | [testenv] 6 | description = Run pytest against {envname}. 7 | extras = 8 | test 9 | 10 | [testenv:py] 11 | description = Run pytest 12 | commands = 13 | pytest {posargs} 14 | 15 | [testenv:lint] 16 | description = Lint codebase by running pre-commit. 17 | skip_install = true 18 | deps = 19 | pre-commit 20 | commands = pre-commit run --all-files 21 | 22 | [testenv:typing] 23 | description = Run mypy. 24 | commands = 25 | mypy nbcollection tests 26 | --------------------------------------------------------------------------------