├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── doc ├── Makefile ├── community │ └── projects.md ├── conf.py ├── environment.yml ├── index.rst ├── make.bat ├── quick-start │ ├── jupyter.md │ └── markdown.md ├── square-no-bg-small.png └── user-guide │ ├── advanced-config.md │ ├── jupyter.md │ ├── markdown.md │ └── publish-medium.md ├── docs-old ├── _tutorial.md ├── build.py ├── jupyblog.yaml ├── output │ └── docs.md ├── post.md ├── script.py └── tutorial.ipynb ├── examples ├── medium │ ├── README.md │ ├── jupyblog.yaml │ └── my-post │ │ ├── ploomber-logo.png │ │ └── post.md ├── quick-start-jupyter │ ├── jupyblog.yaml │ └── my-post │ │ └── post.ipynb └── quick-start-static │ ├── jupyblog.yaml │ └── my-post │ ├── ploomber-logo.png │ └── post.md ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── jupyblog │ ├── __init__.py │ ├── ast.py │ ├── cli.py │ ├── config.py │ ├── exceptions.py │ ├── execute.py │ ├── expand.py │ ├── images.py │ ├── md.py │ ├── medium.py │ ├── models.py │ ├── postprocess.py │ ├── util.py │ └── utm.py └── tests ├── assets ├── expand-placeholder │ ├── another.md │ ├── content │ │ └── posts │ │ │ └── .empty │ ├── functions.py │ ├── jupyblog.yaml │ ├── post.md │ ├── script.py │ └── static │ │ └── .empty ├── image-nested │ ├── content │ │ └── posts │ │ │ └── .empty │ ├── images │ │ └── jupyter.png │ ├── jupyblog.yaml │ ├── post.md │ └── static │ │ └── .empty ├── image │ ├── content │ │ └── posts │ │ │ └── .empty │ ├── jupyblog.yaml │ ├── jupyter.png │ ├── post.md │ └── static │ │ └── .empty ├── sample.md ├── sample_post │ ├── content │ │ └── posts │ │ │ └── .empty │ ├── jupyblog.yaml │ ├── post.md │ └── static │ │ └── .empty └── with_py_code │ ├── content │ └── posts │ │ └── .empty │ ├── jupyblog.yaml │ ├── post.md │ └── static │ └── .empty ├── conftest.py ├── test_ast.py ├── test_cli.py ├── test_config.py ├── test_execute.py ├── test_expand.py ├── test_find_block_lines.py ├── test_images.py ├── test_md.py ├── test_medium.py ├── test_models.py ├── test_render.py ├── test_util.py └── test_utm.py /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | unit-test: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.10", "3.11", "3.12"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Lint with flake8 20 | run: | 21 | pip install --upgrade pip 22 | pip install flake8 23 | flake8 24 | 25 | - name: Install dependencies 26 | run: | 27 | pip install . 28 | # check package is importable 29 | python -c "import jupyblog" 30 | python -c "import jupyblog.cli" 31 | pip install ".[dev]" 32 | 33 | - name: Test with pytest 34 | run: | 35 | pytest 36 | 37 | readme-test: 38 | 39 | runs-on: ubuntu-latest 40 | strategy: 41 | matrix: 42 | python-version: [3.9] 43 | 44 | steps: 45 | - uses: actions/checkout@v2 46 | - name: Set up Python ${{ matrix.python-version }} 47 | uses: actions/setup-python@v2 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | - name: Install jupyblog 51 | run: | 52 | pip install --upgrade pip 53 | pip install nbclient pkgmt 54 | pip install . 55 | - name: Test readme 56 | run: | 57 | pkgmt test-md --file README.md --inplace 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | README.ipynb 3 | docs-old/output/ 4 | doc/_build 5 | out.* 6 | 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | pip-wheel-metadata/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "mambaforge-4.10" 7 | 8 | conda: 9 | environment: doc/environment.yml 10 | 11 | sphinx: 12 | builder: html 13 | fail_on_warning: false -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.0.16dev 4 | 5 | ## 0.0.15 (2025-01-10) 6 | 7 | * [Feature] Copying `.webm` files when rendering posts 8 | 9 | ## 0.0.14 (2024-03-25) 10 | 11 | * [Feature] Keep existing date if the current file has been rendered already 12 | * [Feature] Add utm codes to front matter url (`marketing.url`) 13 | * [Feature] Adding `jupyblog.version_jupyblog` to rendered markdown files 14 | * [Feature] Only add UTM tags to URLs included in `utm_base_urls` (`jupyblog.yml`) 15 | 16 | ## 0.0.13 (2023-03-14) 17 | 18 | * [Feature] Rendering fails if front matter template contains undefined placeholders 19 | * [Feature] Front matter template now accepts environment variables as parameters: `{{env.ENV_VAR}}` 20 | * [Fix] Rendering a `post.ipynb` file no londer needs a copy in markdown format (`post.md`) 21 | 22 | ## 0.0.12 (2023-02-24) 23 | 24 | * [Feature] Adds `jupyblog tomd` to export Jupyter notebooks with outputs to Markdown 25 | * [Fix] Keeping `%%sql` magic when exporting to Markdown 26 | 27 | ## 0.0.11 (2022-12-21) 28 | 29 | * Fixes error when expanding utms in posts where the same base url appeared more than once 30 | 31 | ## 0.0.10 (2022-11-17) 32 | 33 | * UTM module ignores URLs inside code fences 34 | 35 | ## 0.0.9 (2022-11-15) 36 | 37 | * Moving image files after rendering a post will also move `.gif` files 38 | 39 | ## 0.0.8 (2022-11-08) 40 | 41 | * Adds UTM CLI `python -m jupyblog.utm --help` 42 | 43 | ## 0.0.7 (2022-11-06) 44 | 45 | * Extract images from outputs in paired notebooks 46 | * Skipping image paths when adding UTM tags 47 | * Rendering plain text outputs from notebooks with the `txt` tag 48 | 49 | ## 0.0.6 (2022-11-05) 50 | 51 | * Adds support for adding UTM tags to links 52 | 53 | ## 0.0.5 (2022-08-30) 54 | 55 | * Updates telemetry key 56 | 57 | ## 0.0.4 (2022-08-13) 58 | 59 | * Adds telemetry 60 | 61 | ## 0.0.3 (2022-04-16) 62 | 63 | * Increases timeout for jupyter executor 64 | * Creates subclass of ClickException, raised when H1 headers appear 65 | * Custom error when missing keys in front matter 66 | * Validating `jupyblog` front matter section 67 | * Adds support for front matter template 68 | * Footer template is optional 69 | * Adds more arguments for custom postprocessor for greater flexibility 70 | 71 | ## 0.0.2 (2022-01-24) 72 | 73 | * `jupyblog.yaml` can be used for config 74 | * Removes `--hugo` option, replaces it with `--local` 75 | * Fixes img tag parser to allow dots, underscores, and spaces 76 | * Adds language_mapping to `jupyblog.yaml` 77 | * Adds option to switch config file 78 | * Adds image_placeholders config option 79 | * Adds `MarkdownAST`, `GistUploader`, and `config.postprocessor` 80 | * Adds `config.processor` 81 | 82 | ## 0.0.1 (2021-10-13) 83 | 84 | * First public release 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright (c) 2022-Present Ploomber Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-exclude __pycache__ 2 | global-exclude *.py[co] 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Jupyblog 3 | 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | 6 | > [!TIP] 7 | > Deploy AI apps for free on [Ploomber Cloud!](https://ploomber.io/?utm_medium=github&utm_source=jupyblog) 8 | 9 | Jupyblog allow you to write blog posts from Jupyter. 10 | 11 | We use it at [Ploomber](https://github.com/ploomber/ploomber) to write [technical blog posts](https://ploomber.io/blog/snapshot-testing/) and publish them in our [Hugo](https://github.com/gohugoio/hugo) blog; however, any engine that takes markdown files works. 12 | 13 | https://user-images.githubusercontent.com/989250/180660666-d1262a07-2cd9-45ae-9019-79d79ef693e9.mp4 14 | 15 | 16 | ## Installation 17 | 18 | ```sh 19 | pip install jupyblog 20 | ``` 21 | 22 | Works with Python 3.7 and higher. 23 | 24 | ## Documentation 25 | 26 | [Click here](https://jupyblog.readthedocs.io) 27 | 28 | ## Support 29 | 30 | For support, feature requests, and product updates: [join our community](https://ploomber.io/community) or follow us on [Twitter](https://twitter.com/ploomber)/[LinkedIn](https://www.linkedin.com/company/ploomber/). 31 | 32 | 33 | ## Telemetry 34 | 35 | We collect anonymous statistics to understand and improve usage. For details, [see here](https://docs.ploomber.io/en/latest/community/user-stats.html) 36 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/community/projects.md: -------------------------------------------------------------------------------- 1 | # Other projects 2 | 3 | Check out other amazing projects brought to you by the [Ploomber](https://ploomber.io/) team! 4 | 5 | - [sklearn-evaluation](https://github.com/ploomber/sklearn-evaluation): Plots 📊 for evaluating ML models, experiment tracking, and more! 6 | - [JupySQL](https://github.com/ploomber/jupysql): Query SQL databases 🔎 from jupyter with a `%sql` magic: `result = %sql SELECT * FROM table` 7 | - [ploomber](https://github.com/ploomber/ploomber): A framework to build and deploy data pipelines ☁️ -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "jupyblog" 21 | copyright = "2022, Ploomber" 22 | author = "Ploomber" 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = ["myst_nb"] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ["_templates"] 34 | 35 | # List of patterns, relative to source directory, that match files and 36 | # directories to ignore when looking for source files. 37 | # This pattern also affects html_static_path and html_extra_path. 38 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 39 | 40 | 41 | # -- Options for HTML output ------------------------------------------------- 42 | 43 | # The theme to use for HTML and HTML Help pages. See the documentation for 44 | # a list of builtin themes. 45 | # 46 | html_theme = "sphinx_book_theme" 47 | 48 | # Add any paths that contain custom static files (such as style sheets) here, 49 | # relative to this directory. They are copied after the builtin static files, 50 | # so a file named "default.css" will overwrite the builtin "default.css". 51 | html_static_path = ["_static"] 52 | 53 | # https://myst-nb.readthedocs.io/en/latest/render/format_code_cells.html#group-into-single-streams 54 | nb_merge_streams = True 55 | 56 | 57 | html_theme_options = { 58 | "announcement": ( 59 | "To launch a tutorial, click on the 🚀 button " 60 | "below! Join us on " 61 | "Slack!" 62 | ) 63 | } 64 | 65 | 66 | html_logo = "square-no-bg-small.png" 67 | -------------------------------------------------------------------------------- /doc/environment.yml: -------------------------------------------------------------------------------- 1 | name: jupyblog-doc 2 | 3 | channels: 4 | - conda-forge 5 | 6 | dependencies: 7 | - python=3.10 8 | - pip 9 | - sphinx 10 | - pip: 11 | - sphinx-book-theme 12 | - myst-nb 13 | - -e .. 14 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | Jupyblog 2 | ======== 3 | 4 | Write technical blog posts from Jupyter. Export them to Medium or any blog engine that supports Markdown. 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Quick Start 9 | 10 | quick-start/jupyter 11 | quick-start/markdown 12 | 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | :caption: User Guide 17 | 18 | user-guide/jupyter 19 | user-guide/markdown 20 | user-guide/advanced-config 21 | user-guide/publish-medium 22 | 23 | 24 | .. toctree:: 25 | :maxdepth: 2 26 | :caption: Community 27 | 28 | community/projects 29 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /doc/quick-start/jupyter.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.5 8 | kernelspec: 9 | display_name: Python 3 (ipykernel) 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Jupyter notebooks 15 | 16 | ## Installation 17 | 18 | ```{code-cell} 19 | %pip install jupyblog --quiet 20 | ``` 21 | 22 | ## Layout 23 | 24 | Projects in jupyblog must have the following structure: 25 | 26 | ```txt 27 | jupyblog.yaml 28 | 29 | post-a/ 30 | post.ipynb 31 | image.png 32 | post-b/ 33 | post.md 34 | image.png 35 | ``` 36 | 37 | `jupyblog.yaml` is a configuration file and each folder must contain a single post along with any images in it. 38 | 39 | +++ 40 | 41 | ## Example 42 | 43 | ```{code-cell} 44 | from pathlib import Path 45 | import urllib.request 46 | 47 | # create folder to store posts 48 | path = Path("posts") 49 | path.mkdir(exist_ok=True) 50 | 51 | # folder to store a specific post 52 | path_to_post = path / "my-jupyter-post" 53 | path_to_post.mkdir(exist_ok=True) 54 | 55 | # config file 56 | urllib.request.urlretrieve( 57 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-jupyter/jupyblog.yaml", 58 | path / "jupyblog.yaml", 59 | ) 60 | 61 | # download post 62 | _ = urllib.request.urlretrieve( 63 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-jupyter/my-post/post.ipynb", 64 | path_to_post / "post.ipynb", 65 | ) 66 | ``` 67 | 68 | The `jupyblog.yaml` file configures where to store the rendered posts along with other settings: 69 | 70 | ```{code-cell} 71 | print(Path("posts/jupyblog.yaml").read_text()) 72 | ``` 73 | 74 | To convert your Jupyter notebook to a markdown file with outputs included: 75 | 76 | ```{code-cell} 77 | %%sh 78 | cd posts/my-jupyter-post 79 | jupytext post.ipynb --to md 80 | jupyblog render 81 | ``` 82 | 83 | You'll see tat the markdown post contains the output cells as new code fences: 84 | 85 | ```{code-cell} 86 | print(Path("posts/content/posts/my-jupyter-post.md").read_text()) 87 | ``` 88 | 89 | ```{code-cell} 90 | # remove example directory 91 | import shutil 92 | 93 | shutil.rmtree("posts") 94 | ``` 95 | -------------------------------------------------------------------------------- /doc/quick-start/markdown.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.5 8 | kernelspec: 9 | display_name: Python 3 (ipykernel) 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Markdown 15 | 16 | You can use `jupyblog` to write posts in Markdown that don't require running code snippets. 17 | 18 | ## Installation 19 | 20 | ```{code-cell} 21 | %pip install jupyblog --quiet 22 | ``` 23 | 24 | ## Layout 25 | 26 | Projects in jupyblog must have the following structure: 27 | 28 | ```txt 29 | jupyblog.yaml 30 | 31 | post-a/ 32 | post.md 33 | image.png 34 | post-b/ 35 | post.ipynb 36 | image.png 37 | ``` 38 | 39 | `jupyblog.yaml` is a configuration file and each folder must contain a single post along with any images in it. 40 | 41 | +++ 42 | 43 | ## Example 44 | 45 | ```{code-cell} 46 | from pathlib import Path 47 | import urllib.request 48 | 49 | # create folder to store posts 50 | path = Path("posts") 51 | path.mkdir(exist_ok=True) 52 | 53 | # folder to store a specific post 54 | path_to_post = path / "my-static-post" 55 | path_to_post.mkdir(exist_ok=True) 56 | 57 | # config file 58 | urllib.request.urlretrieve( 59 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-static/jupyblog.yaml", 60 | path / "jupyblog.yaml", 61 | ) 62 | 63 | # download post 64 | urllib.request.urlretrieve( 65 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-static/my-post/post.md", 66 | path_to_post / "post.md", 67 | ) 68 | # download image used in post 69 | _ = urllib.request.urlretrieve( 70 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-static/my-post/ploomber-logo.png", 71 | path_to_post / "ploomber-logo.png", 72 | ) 73 | ``` 74 | 75 | The `jupyblog.yaml` file configures where to store the rendered posts along with other settings: 76 | 77 | ```{code-cell} 78 | print(Path("posts/jupyblog.yaml").read_text()) 79 | ``` 80 | 81 | To render your post: 82 | 83 | ```{code-cell} 84 | %%sh 85 | cd posts/my-static-post 86 | jupyblog render 87 | ``` 88 | 89 | Since we're not running code, the only change we'll see is the `prefix_img` applied to all image paths. However, you can also customize the configuration to automate other things such as adding the author information, current date, etc. 90 | 91 | ```{code-cell} 92 | # remove example directory 93 | import shutil 94 | 95 | shutil.rmtree("posts") 96 | ``` 97 | -------------------------------------------------------------------------------- /doc/square-no-bg-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/doc/square-no-bg-small.png -------------------------------------------------------------------------------- /doc/user-guide/advanced-config.md: -------------------------------------------------------------------------------- 1 | # Advanced configuration 2 | 3 | 4 | ## Default front matter 5 | 6 | You can add a default front matter to all your posts, add this toy our `jupyblog.yaml`: 7 | 8 | ```yaml 9 | front_matter_template: front-matter.yaml 10 | ``` 11 | 12 | Then, store your default front matter in a `front-matter.yaml` file. For example, let's say your blog engine takes author information in an `author_info` field: 13 | 14 | ```yaml 15 | author_info: 16 | name: "John Doe" 17 | image: "images/author/john-doe.png" 18 | ``` 19 | 20 | You can also use a few placeholders that will be resolved when rendering your post: 21 | 22 | ```yaml 23 | # {{name}} resolves to the name of your post (the folder name that contains it) 24 | image: "images/blog/{{name}}.png" 25 | 26 | # {{now}} resolves to the current timestamp 27 | date: '{{now}}' 28 | ``` 29 | 30 | 31 | ## UTM tags 32 | 33 | To include UTM tags to all links in a posts, add the following to your `jupyblog.yaml` file: 34 | 35 | ```yaml 36 | utm_source: some-source 37 | utm_medium: some-medium 38 | ``` 39 | -------------------------------------------------------------------------------- /doc/user-guide/jupyter.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.5 8 | kernelspec: 9 | display_name: Python 3 (ipykernel) 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Jupyter notebooks 15 | 16 | `jupyblog` can turn your Jupyter notebooks into markdown (`.md`) files with embedded outputs. It supports plots and HTML outputs. 17 | 18 | ## Installation 19 | 20 | ```{code-cell} ipython3 21 | %pip install jupyblog --quiet 22 | ``` 23 | 24 | ## Example 25 | 26 | Let's run an example, we'll download a configuration file (`jupyblog.yaml`) and a sample post: 27 | 28 | ```{code-cell} ipython3 29 | from pathlib import Path 30 | import urllib.request 31 | 32 | # create folder to store posts 33 | path = Path("posts") 34 | path.mkdir(exist_ok=True) 35 | 36 | # folder to store a specific post 37 | path_to_post = path / "my-jupyter-post" 38 | path_to_post.mkdir(exist_ok=True) 39 | 40 | # config file 41 | urllib.request.urlretrieve( 42 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-jupyter/jupyblog.yaml", 43 | path / "jupyblog.yaml", 44 | ) 45 | 46 | # download post 47 | _ = urllib.request.urlretrieve( 48 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-jupyter/my-post/post.ipynb", 49 | path_to_post / "post.ipynb", 50 | ) 51 | ``` 52 | 53 | We stored everything in a `posts/` directory, this is the structure that `jupyblog` expectds: a directory with a `jupyblog.yaml` configuration file and one directory per post: 54 | 55 | ```{code-cell} ipython3 56 | %ls posts/ 57 | ``` 58 | 59 | The configuration file sets a few settings: 60 | 61 | - `path_to_posts`: Where to store the rendered posts (path is relative to the `posts/` directory 62 | - `path_to_static`: Where to store any images referenced in the post (path is relative to the `posts/` directory 63 | - `prefix_img`: A prefix that will be applied to all image paths (e.g., `![img](path.png)` becomes `![img](/images/blog/path.png)`) 64 | 65 | These settings will depend on our blog structure configuration, these values are examples. 66 | 67 | ```{code-cell} ipython3 68 | print(Path("posts/jupyblog.yaml").read_text()) 69 | ``` 70 | 71 | Posts are organized in folders. Inside each folder we have a post file (`post.ipynb` in this case) and any associated images. This means that you can reference your images with relative paths (e.g., `![img](path/to/image.png)`) so you can preview them with any Markdown editor. 72 | 73 | ```{code-cell} ipython3 74 | %%sh 75 | ls posts/my-jupyter-post/ 76 | ``` 77 | 78 | The only requirement for the notebook is to have a [raw cell](https://nbsphinx.readthedocs.io/en/0.2.4/raw-cells.html) at the top with the following format: 79 | 80 | ```yaml 81 | --- 82 | title: My post 83 | jupyblog: 84 | execute_code: false 85 | description: Some post description 86 | --- 87 | ``` 88 | 89 | Title is the title of the post, the `jupyblog` section can be copied as-is, and description is the blog post description (a one-sentence summary) 90 | 91 | +++ 92 | 93 | Now, we use `jupyblog` to create our post: 94 | 95 | ```{code-cell} ipython3 96 | %%sh 97 | cd posts/my-jupyter-post 98 | jupyblog render 99 | ``` 100 | 101 | In our configuration file (`jupyblog.yaml`), we said we wanted to store our rendered posts in the `content/posts/` directory, let's look at it: 102 | 103 | ```{code-cell} ipython3 104 | %ls posts/content/posts/ 105 | ``` 106 | 107 | We see that it contains a file our rendered post, let's look at its content. You'll see that it's the same content as your notebook, except it contains new code fences with the outputs of each cell: 108 | 109 | ```{code-cell} ipython3 110 | print(Path("posts/content/posts/my-jupyter-post.md").read_text()) 111 | ``` 112 | 113 | ```{code-cell} ipython3 114 | # remove example directory 115 | import shutil 116 | 117 | shutil.rmtree("posts") 118 | ``` 119 | 120 | ```{code-cell} ipython3 121 | 122 | ``` 123 | -------------------------------------------------------------------------------- /doc/user-guide/markdown.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.5 8 | kernelspec: 9 | display_name: Python 3 (ipykernel) 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Markdown 15 | 16 | ## Installation 17 | 18 | ```{code-cell} ipython3 19 | %pip install jupyblog --quiet 20 | ``` 21 | 22 | ## Example 23 | 24 | Let's download the example files: 25 | 26 | 1. `jupyblog.yaml`: configuration file 27 | 2. `post.md`: post content 28 | 3. `ploomber-logo.png`: sample image 29 | 30 | ```{code-cell} ipython3 31 | from pathlib import Path 32 | import urllib.request 33 | 34 | # create folder to store posts 35 | path = Path("posts") 36 | path.mkdir(exist_ok=True) 37 | 38 | # folder to store a specific post 39 | path_to_post = path / "my-static-post" 40 | path_to_post.mkdir(exist_ok=True) 41 | 42 | # config file 43 | urllib.request.urlretrieve( 44 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-static/jupyblog.yaml", 45 | path / "jupyblog.yaml", 46 | ) 47 | 48 | # download post 49 | urllib.request.urlretrieve( 50 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-static/my-post/post.md", 51 | path_to_post / "post.md", 52 | ) 53 | # download image used in post 54 | _ = urllib.request.urlretrieve( 55 | "https://raw.githubusercontent.com/ploomber/jupyblog/master/examples/quick-start-static/my-post/ploomber-logo.png", 56 | path_to_post / "ploomber-logo.png", 57 | ) 58 | ``` 59 | 60 | We stored everything in a `posts/` directory, this is the structure that `jupyblog` expectds: a directory with a `jupyblog.yaml` configuration file and one directory per post: 61 | 62 | ```{code-cell} ipython3 63 | %ls posts/ 64 | ``` 65 | 66 | The configuration file sets a few settings: 67 | 68 | - `path_to_posts`: Where to store the rendered posts (path is relative to the `posts/` directory 69 | - `path_to_static`: Where to store any images referenced in the post (path is relative to the `posts/` directory 70 | - `prefix_img`: A prefix that will be applied to all image paths (e.g., `![img](path.png)` becomes `![img](/images/blog/path.png)`) 71 | 72 | These settings will depend on our blog structure configuration, these values are examples. 73 | 74 | ```{code-cell} ipython3 75 | print(Path("posts/jupyblog.yaml").read_text()) 76 | ``` 77 | 78 | Posts are organized in folders. Inside each folder we have a post file (`post.md` in this case) and any associated images. This means that you can reference your images with relative paths (e.g., `![img](ploomber-logo.png)`) so you can preview them with any Markdown editor. 79 | 80 | ```{code-cell} ipython3 81 | %ls posts/my-static-post/ 82 | ``` 83 | 84 | Let's look at the contents of our post: 85 | 86 | ```{code-cell} ipython3 87 | print(Path("posts/my-static-post/post.md").read_text()) 88 | ``` 89 | 90 | Note that the Markdown file contains a configuration section at the top: 91 | 92 | ```yaml 93 | --- 94 | title: My post 95 | jupyblog: 96 | execute_code: false 97 | description: Some post description 98 | --- 99 | ``` 100 | 101 | Title is the title of the post, the `jupyblog` section can be copied as-is, and description is the blog post description (a one-sentence summary). 102 | 103 | Let's now render the post: 104 | 105 | ```{code-cell} ipython3 106 | %%sh 107 | cd posts/my-static-post 108 | jupyblog render 109 | ``` 110 | 111 | In our configuration file (`jupyblog.yaml`), we said we wanted to store our rendered posts in the `content/posts/` directory, let's look at it: 112 | 113 | ```{code-cell} ipython3 114 | %ls posts/content/posts/ 115 | ``` 116 | 117 | Let's look at our rendered post. You'll see that the content is the same, except the `prefix_img` (`/images/blog` was added): 118 | 119 | ```{code-cell} ipython3 120 | print(Path("posts/content/posts/my-static-post.md").read_text()) 121 | ``` 122 | 123 | ```{code-cell} ipython3 124 | # remove example directory 125 | import shutil 126 | 127 | shutil.rmtree("posts") 128 | ``` 129 | -------------------------------------------------------------------------------- /doc/user-guide/publish-medium.md: -------------------------------------------------------------------------------- 1 | # Publishing to medium -------------------------------------------------------------------------------- /docs-old/_tutorial.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupyter: 3 | jupytext: 4 | text_representation: 5 | extension: .md 6 | format_name: markdown 7 | format_version: '1.3' 8 | jupytext_version: 1.14.5 9 | kernelspec: 10 | display_name: Python 3 (ipykernel) 11 | language: python 12 | name: python3 13 | --- 14 | 15 | 16 | # Documentation 17 | 18 | Jupyblog executes markdown files using Jupyter and inserts the output in new code blocks. 19 | 20 | For example: 21 | 22 | **Input** 23 | 24 | ~~~md 25 | 26 | # My markdown post 27 | 28 | Some description 29 | 30 | ```python 31 | 1 + 1 32 | ``` 33 | ~~~ 34 | 35 | 36 | **Output** 37 | 38 | ~~~md 39 | 40 | # My markdown post 41 | 42 | Some description 43 | 44 | ```python 45 | 1 + 1 46 | ``` 47 | 48 | **Console output (1/1):** 49 | 50 | ``` 51 | 2 52 | ``` 53 | 54 | ~~~ 55 | 56 | 57 | ## Example 58 | 59 | Let's see the contents of `post.md`: 60 | 61 | ```python tags=["hide-source"] 62 | from pathlib import Path 63 | 64 | print(Path("post.md").read_text()) 65 | ``` 66 | 67 | Put a `jupylog.yaml` file next to your `post.md` (or in a parent directory) with the output paths: 68 | 69 | ```python 70 | print(Path("jupyblog.yaml").read_text()) 71 | ``` 72 | 73 | Now, let's render the file using `jupyblog`: 74 | 75 | ```sh 76 | # execute this in your terminal 77 | jupyblog render 78 | ``` 79 | 80 | **Important:** If `jupyter render` errors, ensure you have mistune 2 installed since it might be downgraded due to another package: 81 | 82 | ```python 83 | import mistune 84 | 85 | print(f"mistune version: {mistune.__version__}") 86 | ``` 87 | 88 | Let's look at the output markdown file: 89 | 90 | ```python 91 | from IPython.display import Markdown 92 | 93 | Markdown("output/docs.md") 94 | ``` 95 | 96 | ## Usage 97 | 98 | `jupyblog` expects the following layout: 99 | 100 | 101 | # all posts must be inside a folder 102 | my-post-name/ 103 | # post contents 104 | post.md 105 | images/ 106 | image.png 107 | another.png 108 | 109 | 110 | 111 | ## Skipping code execution 112 | 113 | If you want to skip some code snippets, use `~~~`: 114 | 115 | 116 | ~~~python 117 | # this sinppet wont be executed 118 | ~~~ 119 | 120 | 121 | 122 | ## Settings 123 | 124 | Use YAML front matter to configure execution jupyblog: 125 | 126 | 127 | --- 128 | jupyblog: 129 | serialize_images: False 130 | allow_expand: False 131 | execute_code: True 132 | --- 133 | 134 | # My post 135 | 136 | Some content 137 | 138 | 139 | 140 | * `serialize_images`: Saves images to external files (`serialized/` directory), otherwise embeds them in the same file as base64 strings 141 | * `allow_expand`: If True, it allows the use of `'{{expand("file.py")'}}` to include the content of a file or `'{{expand("file.py@symbol")'}}` to replace with a specific symbol in such file. 142 | * `execute_code`: Execute code snippets. 143 | -------------------------------------------------------------------------------- /docs-old/build.py: -------------------------------------------------------------------------------- 1 | import jupytext 2 | import nbclient 3 | from jupytext.config import JupytextConfiguration 4 | 5 | c = JupytextConfiguration() 6 | c.notebook_metadata_filter 7 | c.cell_metadata_filter = "-all" 8 | 9 | nb = jupytext.read("_tutorial.md") 10 | 11 | # cells = [] 12 | 13 | out = nbclient.execute(nb) 14 | 15 | # for cell in nb.cells: 16 | # if 'hide' not in cell.metadata.get('tags', []): 17 | # cells.append(cell) 18 | 19 | # if 'hide-source' in cell.metadata.get('tags', []): 20 | # cell['source'] = '' 21 | # cells.append(cell) 22 | 23 | # nb.cells = cells 24 | 25 | jupytext.write(out, "tutorial.ipynb") 26 | -------------------------------------------------------------------------------- /docs-old/jupyblog.yaml: -------------------------------------------------------------------------------- 1 | path_to_posts: output 2 | path_to_static: output/static -------------------------------------------------------------------------------- /docs-old/output/docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Some post 3 | jupyblog: 4 | allow_expand: true 5 | title: My post 6 | --- 7 | 8 | ## Expression 9 | 10 | ```python 11 | 1 + 1 12 | ``` 13 | 14 | 15 | **Console output: (1/1):** 16 | 17 | ``` 18 | 2 19 | ``` 20 | 21 | 22 | ## Many outputs 23 | 24 | ```python 25 | print(1) 26 | print(2) 27 | ``` 28 | 29 | 30 | **Console output: (1/1):** 31 | 32 | ``` 33 | 1 34 | 2 35 | ``` 36 | 37 | 38 | ## Exceptions 39 | 40 | ```python 41 | raise ValueError('some error') 42 | ``` 43 | 44 | 45 | **Console output: (1/1):** 46 | 47 | ``` 48 | --------------------------------------------------------------------------- 49 | ValueError Traceback (most recent call last) 50 | Input In [3], in 51 | ----> 1 raise ValueError('some error') 52 | 53 | ValueError: some error 54 | ``` 55 | 56 | 57 | ## Tables 58 | 59 | ```python 60 | import pandas as pd 61 | pd.DataFrame({'x': [1, 2, 3]}) 62 | ``` 63 | 64 | 65 | **Console output: (1/1):** 66 | 67 |
68 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 |
x
01
12
23
103 |
104 | 105 | ## Plots 106 | 107 | ```python 108 | import matplotlib.pyplot as plt 109 | plt.plot([1, 2, 3]) 110 | ``` 111 | 112 | 113 | **Console output: (1/2):** 114 | 115 | ``` 116 | [] 117 | ``` 118 | 119 | **Console output: (2/2):** 120 | 121 | 122 | 123 | **Note:** The plot won't be visible from GitHub since it doesn't support 124 | base64 embedded images, but you can download the file and open it with any 125 | markdown viewer. Jupyblog also supports storing images in external files 126 | (e.g. `static/plot.png`) 127 | 128 | ## Import files 129 | 130 | ```python 131 | # Content of script.py 132 | def add(x, y): 133 | return x + y 134 | 135 | 136 | add(1, 2) 137 | 138 | ``` 139 | 140 | 141 | ## Import symbols 142 | 143 | ```python 144 | # Content of script.py 145 | def add(x, y): 146 | return x + y 147 | 148 | ``` -------------------------------------------------------------------------------- /docs-old/post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: My post 3 | description: Some post 4 | jupyblog: 5 | allow_expand: True 6 | --- 7 | 8 | ## Expression 9 | 10 | ```python 11 | 1 + 1 12 | ``` 13 | 14 | ## Many outputs 15 | 16 | ```python 17 | print(1) 18 | print(2) 19 | ``` 20 | 21 | ## Exceptions 22 | 23 | ```python 24 | raise ValueError('some error') 25 | ``` 26 | 27 | ## Tables 28 | 29 | ```python 30 | import pandas as pd 31 | pd.DataFrame({'x': [1, 2, 3]}) 32 | ``` 33 | 34 | ## Plots 35 | 36 | ```python 37 | import matplotlib.pyplot as plt 38 | plt.plot([1, 2, 3]) 39 | ``` 40 | 41 | **Note:** The plot won't be visible from GitHub since it doesn't support 42 | base64 embedded images, but you can download the file and open it with any 43 | markdown viewer. Jupyblog also supports storing images in external files 44 | (e.g. `static/plot.png`) 45 | 46 | ## Import files 47 | 48 | {{expand("script.py")}} 49 | 50 | 51 | ## Import symbols 52 | 53 | {{expand("script.py@add")}} -------------------------------------------------------------------------------- /docs-old/script.py: -------------------------------------------------------------------------------- 1 | def add(x, y): 2 | return x + y 3 | 4 | 5 | add(1, 2) 6 | -------------------------------------------------------------------------------- /docs-old/tutorial.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "aa62431f", 6 | "metadata": {}, 7 | "source": [ 8 | "# Documentation\n", 9 | "\n", 10 | "Jupyblog executes markdown files using Jupyter and inserts the output in new code blocks.\n", 11 | "\n", 12 | "For example:\n", 13 | "\n", 14 | "**Input**\n", 15 | "\n", 16 | "~~~md\n", 17 | "\n", 18 | "# My markdown post\n", 19 | "\n", 20 | "Some description\n", 21 | "\n", 22 | "```python\n", 23 | "1 + 1\n", 24 | "```\n", 25 | "~~~\n", 26 | "\n", 27 | "\n", 28 | "**Output**\n", 29 | "\n", 30 | "~~~md\n", 31 | "\n", 32 | "# My markdown post\n", 33 | "\n", 34 | "Some description\n", 35 | "\n", 36 | "```python\n", 37 | "1 + 1\n", 38 | "```\n", 39 | "\n", 40 | "**Console output (1/1):**\n", 41 | "\n", 42 | "```\n", 43 | "2\n", 44 | "```\n", 45 | "\n", 46 | "~~~" 47 | ] 48 | }, 49 | { 50 | "cell_type": "markdown", 51 | "id": "46879f40", 52 | "metadata": {}, 53 | "source": [ 54 | "## Example\n", 55 | "\n", 56 | "Let's see the contents of `post.md`:" 57 | ] 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": 1, 62 | "id": "c15a0cdf", 63 | "metadata": { 64 | "execution": { 65 | "iopub.execute_input": "2022-07-19T14:18:59.418219Z", 66 | "iopub.status.busy": "2022-07-19T14:18:59.417857Z", 67 | "iopub.status.idle": "2022-07-19T14:18:59.426716Z", 68 | "shell.execute_reply": "2022-07-19T14:18:59.426070Z" 69 | }, 70 | "tags": [ 71 | "hide-source" 72 | ] 73 | }, 74 | "outputs": [ 75 | { 76 | "name": "stdout", 77 | "output_type": "stream", 78 | "text": [ 79 | "---\n", 80 | "title: My post\n", 81 | "description: Some post\n", 82 | "jupyblog:\n", 83 | " allow_expand: True\n", 84 | "---\n", 85 | "\n", 86 | "## Expression\n", 87 | "\n", 88 | "```python\n", 89 | "1 + 1\n", 90 | "```\n", 91 | "\n", 92 | "## Many outputs\n", 93 | "\n", 94 | "```python\n", 95 | "print(1)\n", 96 | "print(2)\n", 97 | "```\n", 98 | "\n", 99 | "## Exceptions\n", 100 | "\n", 101 | "```python\n", 102 | "raise ValueError('some error')\n", 103 | "```\n", 104 | "\n", 105 | "## Tables\n", 106 | "\n", 107 | "```python\n", 108 | "import pandas as pd\n", 109 | "pd.DataFrame({'x': [1, 2, 3]})\n", 110 | "```\n", 111 | "\n", 112 | "## Plots\n", 113 | "\n", 114 | "```python\n", 115 | "import matplotlib.pyplot as plt\n", 116 | "plt.plot([1, 2, 3])\n", 117 | "```\n", 118 | "\n", 119 | "## Import files\n", 120 | "\n", 121 | "{{expand(\"script.py\")}}\n", 122 | "\n", 123 | "\n", 124 | "## Import symbols\n", 125 | "\n", 126 | "{{expand(\"script.py@add\")}}\n" 127 | ] 128 | } 129 | ], 130 | "source": [ 131 | "from pathlib import Path\n", 132 | "\n", 133 | "print(Path(\"post.md\").read_text())" 134 | ] 135 | }, 136 | { 137 | "cell_type": "markdown", 138 | "id": "21319a17", 139 | "metadata": {}, 140 | "source": [ 141 | "Put a `jupylog.yaml` file next to your `post.md` (or in a parent directory) with the output paths:" 142 | ] 143 | }, 144 | { 145 | "cell_type": "code", 146 | "execution_count": 2, 147 | "id": "d611f7e1", 148 | "metadata": { 149 | "execution": { 150 | "iopub.execute_input": "2022-07-19T14:18:59.430159Z", 151 | "iopub.status.busy": "2022-07-19T14:18:59.429827Z", 152 | "iopub.status.idle": "2022-07-19T14:18:59.434393Z", 153 | "shell.execute_reply": "2022-07-19T14:18:59.433545Z" 154 | } 155 | }, 156 | "outputs": [ 157 | { 158 | "name": "stdout", 159 | "output_type": "stream", 160 | "text": [ 161 | "path_to_posts: output\n", 162 | "path_to_static: output/static\n" 163 | ] 164 | } 165 | ], 166 | "source": [ 167 | "print(Path(\"jupyblog.yaml\").read_text())" 168 | ] 169 | }, 170 | { 171 | "cell_type": "markdown", 172 | "id": "2d4fefff", 173 | "metadata": {}, 174 | "source": [ 175 | "Now, let's render the file using `jupyblog`:" 176 | ] 177 | }, 178 | { 179 | "cell_type": "code", 180 | "execution_count": 3, 181 | "id": "00d46df9", 182 | "metadata": { 183 | "execution": { 184 | "iopub.execute_input": "2022-07-19T14:18:59.437778Z", 185 | "iopub.status.busy": "2022-07-19T14:18:59.437442Z", 186 | "iopub.status.idle": "2022-07-19T14:19:05.014545Z", 187 | "shell.execute_reply": "2022-07-19T14:19:05.012996Z" 188 | } 189 | }, 190 | "outputs": [ 191 | { 192 | "name": "stdout", 193 | "output_type": "stream", 194 | "text": [ 195 | "Input: /Users/Edu/dev/jupyblog/docs\n" 196 | ] 197 | }, 198 | { 199 | "name": "stdout", 200 | "output_type": "stream", 201 | "text": [ 202 | "Processing post \"docs\"\n" 203 | ] 204 | }, 205 | { 206 | "name": "stdout", 207 | "output_type": "stream", 208 | "text": [ 209 | "Post will be saved to /Users/Edu/dev/jupyblog/docs/output\n" 210 | ] 211 | }, 212 | { 213 | "name": "stdout", 214 | "output_type": "stream", 215 | "text": [ 216 | "Rendering markdown...\n" 217 | ] 218 | }, 219 | { 220 | "name": "stdout", 221 | "output_type": "stream", 222 | "text": [ 223 | "Making img links absolute and adding canonical name as prefix...\n" 224 | ] 225 | }, 226 | { 227 | "name": "stdout", 228 | "output_type": "stream", 229 | "text": [ 230 | "Output: /Users/Edu/dev/jupyblog/docs/output/docs.md\n" 231 | ] 232 | } 233 | ], 234 | "source": [ 235 | "%%sh\n", 236 | "# execute this in your terminal\n", 237 | "jupyblog render" 238 | ] 239 | }, 240 | { 241 | "cell_type": "markdown", 242 | "id": "40945a5e", 243 | "metadata": {}, 244 | "source": [ 245 | "**Important:** If `jupyter render` errors, ensure you have mistune 2 installed since it might be downgraded due to another package:" 246 | ] 247 | }, 248 | { 249 | "cell_type": "code", 250 | "execution_count": 4, 251 | "id": "bc8b2f11", 252 | "metadata": { 253 | "execution": { 254 | "iopub.execute_input": "2022-07-19T14:19:05.019003Z", 255 | "iopub.status.busy": "2022-07-19T14:19:05.018596Z", 256 | "iopub.status.idle": "2022-07-19T14:19:05.037297Z", 257 | "shell.execute_reply": "2022-07-19T14:19:05.036618Z" 258 | } 259 | }, 260 | "outputs": [ 261 | { 262 | "name": "stdout", 263 | "output_type": "stream", 264 | "text": [ 265 | "mistune version: 2.0.4\n" 266 | ] 267 | } 268 | ], 269 | "source": [ 270 | "import mistune\n", 271 | "\n", 272 | "print(f\"mistune version: {mistune.__version__}\")" 273 | ] 274 | }, 275 | { 276 | "cell_type": "markdown", 277 | "id": "a2b57200", 278 | "metadata": {}, 279 | "source": [ 280 | "Let's look at the output markdown file:" 281 | ] 282 | }, 283 | { 284 | "cell_type": "code", 285 | "execution_count": 5, 286 | "id": "e042b831", 287 | "metadata": { 288 | "execution": { 289 | "iopub.execute_input": "2022-07-19T14:19:05.040536Z", 290 | "iopub.status.busy": "2022-07-19T14:19:05.040204Z", 291 | "iopub.status.idle": "2022-07-19T14:19:05.052034Z", 292 | "shell.execute_reply": "2022-07-19T14:19:05.051359Z" 293 | } 294 | }, 295 | "outputs": [ 296 | { 297 | "data": { 298 | "text/markdown": [ 299 | "---\n", 300 | "description: Some post\n", 301 | "jupyblog:\n", 302 | " allow_expand: true\n", 303 | "title: My post\n", 304 | "---\n", 305 | "\n", 306 | "## Expression\n", 307 | "\n", 308 | "```python\n", 309 | "1 + 1\n", 310 | "```\n", 311 | "\n", 312 | "\n", 313 | "**Console output (1/1):**\n", 314 | "\n", 315 | "```\n", 316 | "2\n", 317 | "```\n", 318 | "\n", 319 | "\n", 320 | "## Many outputs\n", 321 | "\n", 322 | "```python\n", 323 | "print(1)\n", 324 | "print(2)\n", 325 | "```\n", 326 | "\n", 327 | "\n", 328 | "**Console output (1/1):**\n", 329 | "\n", 330 | "```\n", 331 | "1\n", 332 | "2\n", 333 | "```\n", 334 | "\n", 335 | "\n", 336 | "## Exceptions\n", 337 | "\n", 338 | "```python\n", 339 | "raise ValueError('some error')\n", 340 | "```\n", 341 | "\n", 342 | "\n", 343 | "**Console output (1/1):**\n", 344 | "\n", 345 | "```\n", 346 | "---------------------------------------------------------------------------\n", 347 | "ValueError Traceback (most recent call last)\n", 348 | "Input In [3], in \n", 349 | "----> 1 raise ValueError('some error')\n", 350 | "\n", 351 | "ValueError: some error\n", 352 | "```\n", 353 | "\n", 354 | "\n", 355 | "## Tables\n", 356 | "\n", 357 | "```python\n", 358 | "import pandas as pd\n", 359 | "pd.DataFrame({'x': [1, 2, 3]})\n", 360 | "```\n", 361 | "\n", 362 | "\n", 363 | "**Console output (1/1):**\n", 364 | "\n", 365 | "
\n", 366 | "\n", 379 | "\n", 380 | " \n", 381 | " \n", 382 | " \n", 383 | " \n", 384 | " \n", 385 | " \n", 386 | " \n", 387 | " \n", 388 | " \n", 389 | " \n", 390 | " \n", 391 | " \n", 392 | " \n", 393 | " \n", 394 | " \n", 395 | " \n", 396 | " \n", 397 | " \n", 398 | " \n", 399 | " \n", 400 | "
x
01
12
23
\n", 401 | "
\n", 402 | "\n", 403 | "## Plots\n", 404 | "\n", 405 | "```python\n", 406 | "import matplotlib.pyplot as plt\n", 407 | "plt.plot([1, 2, 3])\n", 408 | "```\n", 409 | "\n", 410 | "\n", 411 | "**Console output (1/2):**\n", 412 | "\n", 413 | "```\n", 414 | "[]\n", 415 | "```\n", 416 | "\n", 417 | "**Console output (2/2):**\n", 418 | "\n", 419 | "\n", 420 | "\n", 421 | "## Import files\n", 422 | "\n", 423 | "```python\n", 424 | "# Content of script.py\n", 425 | "def add(x, y):\n", 426 | " return x + y\n", 427 | "\n", 428 | "\n", 429 | "add(1, 2)\n", 430 | "\n", 431 | "```\n", 432 | "\n", 433 | "\n", 434 | "## Import symbols\n", 435 | "\n", 436 | "```python\n", 437 | "# Content of script.py\n", 438 | "def add(x, y):\n", 439 | " return x + y\n", 440 | "\n", 441 | "```" 442 | ], 443 | "text/plain": [ 444 | "" 445 | ] 446 | }, 447 | "execution_count": 5, 448 | "metadata": {}, 449 | "output_type": "execute_result" 450 | } 451 | ], 452 | "source": [ 453 | "from IPython.display import Markdown\n", 454 | "\n", 455 | "Markdown(\"output/docs.md\")" 456 | ] 457 | }, 458 | { 459 | "cell_type": "markdown", 460 | "id": "373fa95e", 461 | "metadata": {}, 462 | "source": [ 463 | "## Usage\n", 464 | "\n", 465 | "`jupyblog` expects the following layout:" 466 | ] 467 | }, 468 | { 469 | "cell_type": "raw", 470 | "id": "0b51dee4", 471 | "metadata": { 472 | "lines_to_next_cell": 2 473 | }, 474 | "source": [ 475 | "# all posts must be inside a folder\n", 476 | "my-post-name/\n", 477 | " # post contents\n", 478 | " post.md\n", 479 | " images/\n", 480 | " image.png\n", 481 | " another.png" 482 | ] 483 | }, 484 | { 485 | "cell_type": "markdown", 486 | "id": "dcc55e99", 487 | "metadata": {}, 488 | "source": [ 489 | "## Skipping code execution\n", 490 | "\n", 491 | "If you want to skip some code snippets, use `~~~`:" 492 | ] 493 | }, 494 | { 495 | "cell_type": "raw", 496 | "id": "554b013e", 497 | "metadata": { 498 | "lines_to_next_cell": 2 499 | }, 500 | "source": [ 501 | "~~~python\n", 502 | "# this sinppet wont be executed\n", 503 | "~~~" 504 | ] 505 | }, 506 | { 507 | "cell_type": "markdown", 508 | "id": "913db43b", 509 | "metadata": {}, 510 | "source": [ 511 | "## Settings\n", 512 | "\n", 513 | "Use YAML front matter to configure execution jupyblog:" 514 | ] 515 | }, 516 | { 517 | "cell_type": "raw", 518 | "id": "1c3d1ab2", 519 | "metadata": { 520 | "title": "md" 521 | }, 522 | "source": [ 523 | "---\n", 524 | "jupyblog:\n", 525 | " serialize_images: False\n", 526 | " allow_expand: False\n", 527 | " execute_code: True\n", 528 | "---\n", 529 | "\n", 530 | "# My post\n", 531 | "\n", 532 | "Some content\n" 533 | ] 534 | }, 535 | { 536 | "cell_type": "markdown", 537 | "id": "642bb04d", 538 | "metadata": {}, 539 | "source": [ 540 | "* `serialize_images`: Saves images to external files (`serialized/` directory), otherwise embeds them in the same file as base64 strings\n", 541 | "* `allow_expand`: If True, it allows the use of `'{{expand(\"file.py\")'}}` to include the content of a file or `'{{expand(\"file.py@symbol\")'}}` to replace with a specific symbol in such file.\n", 542 | "* `execute_code`: Execute code snippets." 543 | ] 544 | } 545 | ], 546 | "metadata": { 547 | "kernelspec": { 548 | "display_name": "Python 3 (ipykernel)", 549 | "language": "python", 550 | "name": "python3" 551 | }, 552 | "language_info": { 553 | "codemirror_mode": { 554 | "name": "ipython", 555 | "version": 3 556 | }, 557 | "file_extension": ".py", 558 | "mimetype": "text/x-python", 559 | "name": "python", 560 | "nbconvert_exporter": "python", 561 | "pygments_lexer": "ipython3", 562 | "version": "3.9.9" 563 | } 564 | }, 565 | "nbformat": 4, 566 | "nbformat_minor": 5 567 | } 568 | -------------------------------------------------------------------------------- /examples/medium/README.md: -------------------------------------------------------------------------------- 1 | 2 | ``` 3 | jupyblog render 4 | ``` -------------------------------------------------------------------------------- /examples/medium/jupyblog.yaml: -------------------------------------------------------------------------------- 1 | path_to_posts: out 2 | path_to_static: out 3 | language_mapping: 4 | python: py 5 | bash: sh 6 | image_placeholders: true 7 | postprocessor: jupyblog.postprocess.upload_to_github -------------------------------------------------------------------------------- /examples/medium/my-post/ploomber-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/examples/medium/my-post/ploomber-logo.png -------------------------------------------------------------------------------- /examples/medium/my-post/post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: My post 3 | jupyblog: 4 | execute_code: false 5 | description: Some post description 6 | --- 7 | 8 | ## My section 9 | 10 | This sentence is some description: 11 | 12 | ```python 13 | import load 14 | 15 | df = load() 16 | ``` 17 | 18 | Let's show a second snippet: 19 | 20 | 21 | ```python 22 | import clean 23 | 24 | df_clean = clean(df) 25 | ``` 26 | 27 | ## Another section 28 | 29 | Next, an image: 30 | 31 | ![ploomber-logo](ploomber-logo.png) -------------------------------------------------------------------------------- /examples/quick-start-jupyter/jupyblog.yaml: -------------------------------------------------------------------------------- 1 | path_to_posts: content/posts 2 | path_to_static: static/images 3 | prefix_img: /images/blog -------------------------------------------------------------------------------- /examples/quick-start-jupyter/my-post/post.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "raw", 5 | "id": "80fe68ec", 6 | "metadata": { 7 | "papermill": { 8 | "duration": 0.005259, 9 | "end_time": "2022-12-21T02:14:19.125255", 10 | "exception": false, 11 | "start_time": "2022-12-21T02:14:19.119996", 12 | "status": "completed" 13 | }, 14 | "tags": [] 15 | }, 16 | "source": [ 17 | "---\n", 18 | "title: My post\n", 19 | "jupyblog:\n", 20 | " execute_code: false\n", 21 | "description: Some post description\n", 22 | "---" 23 | ] 24 | }, 25 | { 26 | "cell_type": "markdown", 27 | "id": "fa4840ef", 28 | "metadata": { 29 | "papermill": { 30 | "duration": 0.002249, 31 | "end_time": "2022-12-21T02:14:19.130472", 32 | "exception": false, 33 | "start_time": "2022-12-21T02:14:19.128223", 34 | "status": "completed" 35 | }, 36 | "tags": [] 37 | }, 38 | "source": [ 39 | "## My section\n", 40 | "\n", 41 | "This sentence is some description:" 42 | ] 43 | }, 44 | { 45 | "cell_type": "code", 46 | "execution_count": 1, 47 | "id": "9c02d00d", 48 | "metadata": { 49 | "execution": { 50 | "iopub.execute_input": "2022-12-21T02:14:19.134891Z", 51 | "iopub.status.busy": "2022-12-21T02:14:19.134540Z", 52 | "iopub.status.idle": "2022-12-21T02:14:19.143205Z", 53 | "shell.execute_reply": "2022-12-21T02:14:19.142766Z" 54 | }, 55 | "papermill": { 56 | "duration": 0.012566, 57 | "end_time": "2022-12-21T02:14:19.144574", 58 | "exception": false, 59 | "start_time": "2022-12-21T02:14:19.132008", 60 | "status": "completed" 61 | }, 62 | "tags": [] 63 | }, 64 | "outputs": [ 65 | { 66 | "name": "stdout", 67 | "output_type": "stream", 68 | "text": [ 69 | "Result is: 42\n" 70 | ] 71 | } 72 | ], 73 | "source": [ 74 | "x = 21\n", 75 | "y = 2\n", 76 | "\n", 77 | "result = x * y\n", 78 | "print(f\"Result is: {result}\")" 79 | ] 80 | }, 81 | { 82 | "cell_type": "markdown", 83 | "id": "380cc3f4", 84 | "metadata": { 85 | "papermill": { 86 | "duration": 0.001071, 87 | "end_time": "2022-12-21T02:14:19.146820", 88 | "exception": false, 89 | "start_time": "2022-12-21T02:14:19.145749", 90 | "status": "completed" 91 | }, 92 | "tags": [] 93 | }, 94 | "source": [ 95 | "Let's show a second snippet:\n" 96 | ] 97 | }, 98 | { 99 | "cell_type": "code", 100 | "execution_count": 2, 101 | "id": "8d7f5813", 102 | "metadata": { 103 | "execution": { 104 | "iopub.execute_input": "2022-12-21T02:14:19.149609Z", 105 | "iopub.status.busy": "2022-12-21T02:14:19.149434Z", 106 | "iopub.status.idle": "2022-12-21T02:14:19.151878Z", 107 | "shell.execute_reply": "2022-12-21T02:14:19.151479Z" 108 | }, 109 | "papermill": { 110 | "duration": 0.005074, 111 | "end_time": "2022-12-21T02:14:19.152962", 112 | "exception": false, 113 | "start_time": "2022-12-21T02:14:19.147888", 114 | "status": "completed" 115 | }, 116 | "tags": [] 117 | }, 118 | "outputs": [ 119 | { 120 | "name": "stdout", 121 | "output_type": "stream", 122 | "text": [ 123 | "Result is: 42\n" 124 | ] 125 | } 126 | ], 127 | "source": [ 128 | "x = 1\n", 129 | "y = 41\n", 130 | "\n", 131 | "result = x + y\n", 132 | "print(f\"Result is: {result}\")" 133 | ] 134 | } 135 | ], 136 | "metadata": { 137 | "jupytext": { 138 | "cell_metadata_filter": "-all", 139 | "main_language": "python", 140 | "notebook_metadata_filter": "-all" 141 | }, 142 | "kernelspec": { 143 | "display_name": "Python 3.10.8 ('vscode')", 144 | "language": "python", 145 | "name": "python3" 146 | }, 147 | "language_info": { 148 | "codemirror_mode": { 149 | "name": "ipython", 150 | "version": 3 151 | }, 152 | "file_extension": ".py", 153 | "mimetype": "text/x-python", 154 | "name": "python", 155 | "nbconvert_exporter": "python", 156 | "pygments_lexer": "ipython3", 157 | "version": "3.10.8" 158 | }, 159 | "papermill": { 160 | "default_parameters": {}, 161 | "duration": 0.999977, 162 | "end_time": "2022-12-21T02:14:19.271496", 163 | "environment_variables": {}, 164 | "exception": null, 165 | "input_path": "post.ipynb", 166 | "output_path": "post.ipynb", 167 | "parameters": {}, 168 | "start_time": "2022-12-21T02:14:18.271519", 169 | "version": "2.4.0" 170 | }, 171 | "vscode": { 172 | "interpreter": { 173 | "hash": "311775ae6d8092fc5ad2ca66a4929014f2bf84f063ca6cd26574943ab663bae2" 174 | } 175 | } 176 | }, 177 | "nbformat": 4, 178 | "nbformat_minor": 5 179 | } 180 | -------------------------------------------------------------------------------- /examples/quick-start-static/jupyblog.yaml: -------------------------------------------------------------------------------- 1 | path_to_posts: content/posts 2 | path_to_static: static/images 3 | prefix_img: /images/blog -------------------------------------------------------------------------------- /examples/quick-start-static/my-post/ploomber-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/examples/quick-start-static/my-post/ploomber-logo.png -------------------------------------------------------------------------------- /examples/quick-start-static/my-post/post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: My post 3 | jupyblog: 4 | execute_code: false 5 | description: Some post description 6 | --- 7 | 8 | ## My section 9 | 10 | This sentence is some description: 11 | 12 | ```python 13 | import load 14 | 15 | df = load() 16 | ``` 17 | 18 | Let's show a second snippet: 19 | 20 | 21 | ```python 22 | import clean 23 | 24 | df_clean = clean(df) 25 | ``` 26 | 27 | ## Another section 28 | 29 | Next, an image: 30 | 31 | ![ploomber-logo](ploomber-logo.png) -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | addopts = "--pdbcls=IPython.terminal.debugger:Pdb" 3 | 4 | [tool.pkgmt] 5 | github = "ploomber/jupyblog" 6 | package_name = "jupyblog" 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import re 3 | import ast 4 | from glob import glob 5 | from os.path import basename 6 | from os.path import dirname 7 | from os.path import join 8 | from os.path import splitext 9 | 10 | from setuptools import find_packages 11 | from setuptools import setup 12 | 13 | _version_re = re.compile(r"__version__\s+=\s+(.*)") 14 | 15 | with open("src/jupyblog/__init__.py", "rb") as f: 16 | VERSION = str( 17 | ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1)) 18 | ) 19 | 20 | 21 | def read(*names, **kwargs): 22 | return io.open( 23 | join(dirname(__file__), *names), encoding=kwargs.get("encoding", "utf8") 24 | ).read() 25 | 26 | 27 | REQUIRES = [ 28 | "pyyaml", 29 | "jinja2", 30 | "jupyter_client", 31 | "ipykernel", 32 | "click", 33 | "jupytext", 34 | "parso", 35 | "pydantic", 36 | "mistune>=2.0,<3", 37 | "ploomber-core>=0.0.4", 38 | "nbformat", 39 | ] 40 | 41 | DEV = [ 42 | "pkgmt", 43 | "pytest", 44 | "yapf", 45 | "flake8", 46 | "invoke", 47 | "twine", 48 | "ipdb", 49 | # for docs example 50 | "matplotlib", 51 | "pandas", 52 | # for testing paired notebooks 53 | "ploomber-engine", 54 | ] 55 | 56 | ALL = [ 57 | "ghapi", 58 | ] 59 | 60 | setup( 61 | name="jupyblog", 62 | version=VERSION, 63 | description=None, 64 | license=None, 65 | author=None, 66 | author_email=None, 67 | url=None, 68 | packages=find_packages("src"), 69 | package_dir={"": "src"}, 70 | py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")], 71 | package_data={"": ["*.txt", "*.rst"]}, 72 | classifiers=[], 73 | keywords=[], 74 | install_requires=REQUIRES, 75 | extras_require={ 76 | "all": ALL, 77 | "dev": DEV + ALL, 78 | }, 79 | entry_points={ 80 | "console_scripts": ["jupyblog=jupyblog.cli:cli"], 81 | }, 82 | ) 83 | -------------------------------------------------------------------------------- /src/jupyblog/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.16dev" 2 | -------------------------------------------------------------------------------- /src/jupyblog/ast.py: -------------------------------------------------------------------------------- 1 | def create_md_parser(): 2 | import mistune 3 | 4 | return mistune.create_markdown(renderer=mistune.AstRenderer()) 5 | 6 | 7 | def _traverse(ast): 8 | for node in ast: 9 | yield node 10 | 11 | if node.get("children"): 12 | yield from _traverse(node["children"]) 13 | 14 | 15 | # TODO: use this in ast executor 16 | class MarkdownAST: 17 | def __init__(self, doc): 18 | parser = create_md_parser() 19 | self.ast_raw = parser(doc) 20 | self.doc = doc 21 | 22 | def iter_blocks(self): 23 | for node in self.ast_raw: 24 | if node["type"] == "block_code": 25 | yield node 26 | 27 | def iter_links(self): 28 | for node in _traverse(self.ast_raw): 29 | if node["type"] == "link": 30 | yield node["link"] 31 | 32 | def replace_blocks(self, blocks_new): 33 | doc = self.doc 34 | 35 | # TODO: support for code fences with structured info 36 | for block, replacement in zip(self.iter_blocks(), blocks_new): 37 | to_replace = f'```{block["info"]}\n{block["text"]}```' 38 | doc = doc.replace(to_replace, replacement) 39 | 40 | return doc 41 | -------------------------------------------------------------------------------- /src/jupyblog/cli.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import logging 3 | from pathlib import Path 4 | 5 | import click 6 | from ploomber_core.telemetry.telemetry import Telemetry 7 | 8 | from jupyblog import __version__ 9 | from jupyblog.md import MarkdownRenderer, parse_metadata, to_md 10 | from jupyblog.expand import expand as _expand 11 | from jupyblog.images import add_image_placeholders 12 | from jupyblog import util, config 13 | from jupyblog import medium as medium_module 14 | 15 | telemetry = Telemetry( 16 | api_key="phc_P9SpSeypyPwxrMdFn2edOOEooQioF2axppyEeDwtMSP", 17 | package_name="jupyblog", 18 | version=__version__, 19 | ) 20 | 21 | 22 | @click.group() 23 | def cli(): 24 | pass 25 | 26 | 27 | @cli.command() 28 | @click.argument("path") 29 | @click.option("--output", "-o", default=None, help="Path to output") 30 | def expand(path, output): 31 | """Expand markdown""" 32 | md = Path(path).read_text() 33 | out = _expand(md, root_path=None) 34 | 35 | if not output: 36 | click.echo(out) 37 | else: 38 | Path(output).write_text(out) 39 | 40 | 41 | @cli.command() 42 | @click.option( 43 | "--local", 44 | "-l", 45 | is_flag=True, 46 | help="Ignore jupyblog.yaml and export to the current working directory", 47 | ) 48 | @click.option( 49 | "--incsource", is_flag=True, help="Whether the source will be on Github or not" 50 | ) 51 | @click.option("--log", default=None, help="Set logging level") 52 | @click.option("--cfg", "-c", default="jupyblog.yaml", help="Config filename") 53 | def render(local, incsource, log, cfg): 54 | return _render(local=local, cfg=cfg, log=log) 55 | 56 | 57 | @telemetry.log_call(action="render") 58 | def _render(local, cfg="jupyblog.yaml", incsource=False, log=None): 59 | """Render markdown 60 | 61 | Parameters 62 | ---------- 63 | local : bool 64 | If True, it renders the post in an output folder, otherwise it looks 65 | up for a jupyter.yaml file and uses it to determine output paths. 66 | 67 | Notes 68 | ----- 69 | * Runs build.sh first if it exists 70 | * Runs cells and include output as new cells (post.md) 71 | * Fix relative links to images (moves images and renames them as well) 72 | * Add datetime to front matter 73 | * Adds jupyblog commit version and command that generated it to front 74 | matter - maybe store it in metadata? 75 | """ 76 | if log: 77 | logging.basicConfig(level=log.upper()) 78 | 79 | path = Path(".").resolve() 80 | 81 | post_name = path.name 82 | 83 | if local: 84 | cfg = config.get_local_config() 85 | else: 86 | cfg = config.get_config(name=cfg) 87 | 88 | # post_dir.mkdir(exist_ok=True, parents=True) 89 | 90 | click.echo(f"Input: {path.resolve()}") 91 | click.echo('Processing post "%s"' % post_name) 92 | click.echo("Post will be saved to %s" % cfg.path_to_posts_abs()) 93 | 94 | if (path / "build.sh").exists(): 95 | click.echo("build.sh found, running...") 96 | subprocess.call(["bash", "build.sh"]) 97 | click.echo("Finished running build.sh\n\n") 98 | 99 | click.echo("Rendering markdown...") 100 | out_path = Path(cfg.path_to_posts_abs(), (post_name + ".md")) 101 | 102 | mdr = MarkdownRenderer( 103 | path_to_mds=path, 104 | path_to_out=out_path, 105 | img_dir=cfg.path_to_static_abs(), 106 | img_prefix=cfg.prefix_img, 107 | footer_template=cfg.read_footer_template(), 108 | front_matter_template=cfg.load_front_matter_template(name=post_name), 109 | utm_medium=cfg.utm_medium, 110 | utm_source=cfg.utm_source, 111 | utm_base_urls=cfg.utm_base_urls, 112 | ) 113 | 114 | # TODO: test that expands based on img_dir 115 | if Path("post.md").exists(): 116 | name_input = "post.md" 117 | elif Path("post.ipynb").exists(): 118 | name_input = "post.ipynb" 119 | else: 120 | raise click.ClickException( 121 | "Expected a post.md or post.ipynb file in the current directory" 122 | ) 123 | 124 | out, name = mdr.render(name=name_input, include_source_in_footer=incsource) 125 | click.echo(f"Output: {out_path}") 126 | 127 | # map language in code snippets if needed 128 | out = medium_module.apply_language_map(out, cfg.language_mapping) 129 | 130 | if cfg.image_placeholders: 131 | out = add_image_placeholders(out) 132 | 133 | processor = cfg.load_processor() 134 | 135 | if processor: 136 | out = processor(out, name=name) 137 | 138 | postprocessor = cfg.load_postprocessor() 139 | 140 | if postprocessor: 141 | print( 142 | postprocessor( 143 | doc=out, name=name, config=dict(cfg), front_matter=parse_metadata(out) 144 | ) 145 | ) 146 | 147 | out_path.write_text(out) 148 | 149 | img_dir = cfg.path_to_static_abs() 150 | 151 | if not img_dir.exists(): 152 | img_dir.mkdir(exist_ok=True, parents=True) 153 | 154 | util.copy_all_images(src=path, target=img_dir, dir_name=post_name) 155 | 156 | 157 | @cli.command() 158 | @click.argument("path") 159 | def tomd(path): 160 | """Export a notebook with outputs to makrdown""" 161 | out = to_md(path) 162 | print(out) 163 | -------------------------------------------------------------------------------- /src/jupyblog/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import yaml 5 | 6 | from jupyblog.models import Config 7 | 8 | 9 | def find_file_recursively(name, max_levels_up=6, starting_dir=None): 10 | """ 11 | Find a file by looking into the current folder and parent folders, 12 | returns None if no file was found otherwise pathlib.Path to the file 13 | 14 | Parameters 15 | ---------- 16 | name : str 17 | Filename 18 | 19 | Returns 20 | ------- 21 | path : str 22 | Absolute path to the file 23 | levels : int 24 | How many levels up the file is located 25 | """ 26 | current_dir = starting_dir or os.getcwd() 27 | current_dir = Path(current_dir).resolve() 28 | path_to_file = None 29 | levels = None 30 | 31 | for levels in range(max_levels_up): 32 | current_path = Path(current_dir, name) 33 | 34 | if current_path.exists(): 35 | path_to_file = current_path.resolve() 36 | break 37 | 38 | current_dir = current_dir.parent 39 | 40 | return path_to_file, levels 41 | 42 | 43 | def get_config(name="jupyblog.yaml"): 44 | """ 45 | Load jupyblog configuration file 46 | """ 47 | 48 | path, _ = find_file_recursively(name) 49 | 50 | if path is None: 51 | raise FileNotFoundError(f"Could not find {name}") 52 | 53 | cfg = Config(**yaml.safe_load(Path(path).read_text()), root=str(path.parent)) 54 | 55 | path_to_posts = cfg.path_to_posts_abs() 56 | path_to_static = cfg.path_to_static_abs() 57 | 58 | Path(path_to_static).mkdir(parents=True, exist_ok=True) 59 | Path(path_to_posts).mkdir(parents=True, exist_ok=True) 60 | 61 | return cfg 62 | 63 | 64 | def get_local_config(): 65 | Path("output").mkdir() 66 | return Config( 67 | path_to_posts="output", path_to_static="output", prefix_img="", root="." 68 | ) 69 | -------------------------------------------------------------------------------- /src/jupyblog/exceptions.py: -------------------------------------------------------------------------------- 1 | from click.exceptions import ClickException 2 | 3 | 4 | class InvalidFrontMatter(ValueError): 5 | pass 6 | 7 | 8 | class InputPostException(ClickException): 9 | pass 10 | -------------------------------------------------------------------------------- /src/jupyblog/execute.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import re 3 | import base64 4 | import shutil 5 | import queue 6 | from collections import defaultdict 7 | import logging 8 | 9 | import jupyter_client 10 | 11 | from jupyblog import models 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class ASTExecutor: 17 | """Execute code chunks from a markdown ast""" 18 | 19 | def __init__(self, wd=None, front_matter=None, img_dir=None, canonical_name=None): 20 | self._session = None 21 | self._front_matter = front_matter 22 | self._img_dir = img_dir 23 | self._canonical_name = canonical_name 24 | self.wd = wd if wd is None else Path(wd) 25 | 26 | def __call__(self, md_ast): 27 | logger.debug("Starting python code execution...") 28 | 29 | if self.wd: 30 | if not self.wd.exists(): 31 | self.wd.mkdir(exist_ok=True, parents=True) 32 | 33 | self._session.execute('import os; os.chdir("{}")'.format(str(self.wd))) 34 | 35 | blocks = [e for e in md_ast if e["type"] == "block_code"] 36 | 37 | # info captures whatever is after the triple ticks, e.g. 38 | # ```python a=1 b=2 39 | # Info: "python a=1 b=1" 40 | 41 | # add parsed info 42 | blocks = [{**block, **parse_info(block["info"])} for block in blocks] 43 | 44 | for block in blocks: 45 | if block.get("info") and not block.get("skip"): 46 | output = self._session.execute(block["text"]) 47 | logger.info("In:\n\t%s", block["text"]) 48 | logger.info("Out:\n\t%s", output) 49 | block["output"] = output 50 | else: 51 | block["output"] = None 52 | 53 | logger.debug("Finished python code execution...") 54 | 55 | return blocks 56 | 57 | def __enter__(self): 58 | self._session = JupyterSession( 59 | front_matter=self._front_matter, 60 | img_dir=self._img_dir, 61 | canonical_name=self._canonical_name, 62 | ) 63 | return self 64 | 65 | def __exit__(self, exc_type, exc_val, exc_tb): 66 | del self._session 67 | self._session = None 68 | 69 | 70 | def parse_info(info): 71 | if info is not None: 72 | elements = info.split(" ") 73 | 74 | if len(elements) == 1: 75 | return {} 76 | 77 | return {t.split("=")[0]: t.split("=")[1] for t in elements[1].split(",")} 78 | else: 79 | return {} 80 | 81 | 82 | class JupyterSession: 83 | """Execute code in a Jupyter kernel and parse results 84 | 85 | Examples 86 | -------- 87 | >>> from jupyblog.execute import JupyterSession 88 | >>> s = JupyterSession() 89 | >>> s.execute('1 + 10') 90 | >>> del s # ensures kernel is shut down 91 | """ 92 | 93 | # Reference for managing kernels 94 | # https://github.com/jupyter/jupyter_client/blob/5742d84ca2162e21179d82e8b36e10baf0f8d978/jupyter_client/manager.py#L660 95 | def __init__(self, front_matter=None, img_dir=None, canonical_name=None): 96 | self.km = jupyter_client.KernelManager() 97 | self.km.start_kernel() 98 | self.kc = self.km.client() 99 | self.kc.start_channels() 100 | self.kc.wait_for_ready() 101 | self.out = defaultdict(lambda: []) 102 | self._front_matter = front_matter or models.FrontMatter() 103 | self._img_dir = img_dir 104 | self._canonical_name = canonical_name 105 | self._counter = 0 106 | 107 | # clean up folder with serialized images if needed 108 | if self._front_matter.jupyblog.serialize_images: 109 | serialized = Path(self._img_dir, self._canonical_name, "serialized") 110 | 111 | if serialized.is_dir(): 112 | shutil.rmtree(serialized) 113 | 114 | def execute(self, code): 115 | out = [] 116 | self.kc.execute(code) 117 | 118 | while True: 119 | try: 120 | io_msg = self.kc.get_iopub_msg(timeout=10) 121 | io_msg_content = io_msg["content"] 122 | if ( 123 | "execution_state" in io_msg_content 124 | and io_msg_content["execution_state"] == "idle" 125 | ): 126 | break 127 | except queue.Empty: 128 | break 129 | 130 | if "execution_state" not in io_msg["content"]: 131 | out.append(io_msg) 132 | 133 | processed = [ 134 | _process_content_data( 135 | o["content"], 136 | self._counter, 137 | idx, 138 | serialize_images=self._front_matter.jupyblog.serialize_images, 139 | img_dir=self._img_dir, 140 | canonical_name=self._canonical_name, 141 | ) 142 | for idx, o in enumerate(out) 143 | ] 144 | 145 | self._counter += 1 146 | return [content for content in processed if content] 147 | 148 | def __del__(self): 149 | self.kc.stop_channels() 150 | self.km.shutdown_kernel(now=True) 151 | 152 | 153 | PLAIN = "text/plain" 154 | HTML = "text/html" 155 | PNG = "image/png" 156 | ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 157 | 158 | 159 | def extract_outputs_from_notebook_cell( 160 | outputs, prefix, serialize_images, img_dir, canonical_name 161 | ): 162 | return [ 163 | _process_content_data( 164 | out, 165 | counter=prefix, 166 | idx=idx, 167 | serialize_images=serialize_images, 168 | img_dir=img_dir, 169 | canonical_name=canonical_name, 170 | ) 171 | for idx, out in enumerate(outputs) 172 | ] 173 | 174 | 175 | def _process_content_data( 176 | content, counter, idx, serialize_images=False, img_dir=None, canonical_name=None 177 | ): 178 | """ 179 | 180 | Parameters 181 | ---------- 182 | content : list 183 | "outputs" key in a notebook's cell 184 | 185 | counter : str 186 | Prefix to apply to image paths. Only used if 187 | serialize_images=True 188 | 189 | idx : str 190 | Suffix to apply to the image path. Only used if 191 | serialize_images=True 192 | 193 | serialize_images : bool, default=False 194 | Serialize images as .png files. Otherwise, embed them as base64 strings 195 | 196 | img_dir : str, default=None 197 | Folder to serialize images. Only used if serialize_images=True 198 | 199 | canonical_name : str, default=None 200 | Used to construct the path to the images for this post: 201 | {img_dir}/{canonical_name}/serialized. Only used if 202 | serialize_images=True 203 | """ 204 | 205 | if "data" in content: 206 | data = content["data"] 207 | 208 | if data.get("image/png"): 209 | image_base64 = data.get("image/png") 210 | 211 | if serialize_images: 212 | serialized = Path(img_dir, canonical_name, "serialized") 213 | serialized.mkdir(exist_ok=True, parents=True) 214 | 215 | id_ = f"{counter}-{idx}" 216 | filename = f"{id_}.png" 217 | path_to_image = serialized / filename 218 | base64_2_image(image_base64, path_to_image) 219 | 220 | return (HTML, f"![{id_}](serialized/{filename})") 221 | else: 222 | return PNG, base64_html_tag(image_base64) 223 | if data.get("text/html"): 224 | return HTML, data.get("text/html") 225 | else: 226 | return PLAIN, data["text/plain"] 227 | elif "text" in content: 228 | out = content["text"].rstrip() 229 | 230 | if out[-1] != "\n": 231 | out = out + "\n" 232 | 233 | return PLAIN, out 234 | elif "traceback" in content: 235 | return PLAIN, remove_ansi_escape("\n".join(content["traceback"])) 236 | 237 | 238 | def remove_ansi_escape(s): 239 | """ 240 | https://stackoverflow.com/a/14693789/709975 241 | """ 242 | return ANSI_ESCAPE.sub("", s) 243 | 244 | 245 | def base64_2_image(message, path_to_image): 246 | bytes = message.encode().strip() 247 | message_bytes = base64.b64decode(bytes) 248 | Path(path_to_image).write_bytes(message_bytes) 249 | 250 | 251 | def base64_html_tag(base64): 252 | return f'' 253 | -------------------------------------------------------------------------------- /src/jupyblog/expand.py: -------------------------------------------------------------------------------- 1 | """ 2 | Expand markdown files that reference external files 3 | """ 4 | from functools import partial, reduce 5 | from pathlib import Path 6 | 7 | import parso 8 | from jinja2 import Template 9 | 10 | _ext2tag = { 11 | ".py": "python", 12 | } 13 | 14 | 15 | def expand( 16 | md, 17 | root_path=None, 18 | args=None, 19 | template_params=None, 20 | header="", 21 | footer="", 22 | **render_params, 23 | ): 24 | """Expand markdown string 25 | 26 | Parameters 27 | ---------- 28 | md : str 29 | Markdown string 30 | 31 | root_path : str 32 | Paths are relative to this one 33 | 34 | args : str 35 | String to add after the triple snippet ticks 36 | 37 | **render_params 38 | Any other keyword arguments to pass to Template.render 39 | """ 40 | expand_partial = partial( 41 | _expand, root_path=root_path, args=args, header=header, footer=footer 42 | ) 43 | return Template(md, **(template_params or {})).render( 44 | expand=expand_partial, **render_params 45 | ) 46 | 47 | 48 | def _expand( 49 | path, root_path=None, args=None, lines=None, header="", footer="", symbols=None 50 | ): 51 | """Function used inside jinja to expand files 52 | 53 | Parameters 54 | ---------- 55 | lines : tuple 56 | start end end line to display, both inclusive 57 | """ 58 | if header: 59 | header = header + "\n" 60 | 61 | if footer: 62 | footer = "\n" + footer 63 | 64 | args = "" if not args else f" {args}" 65 | 66 | elements = path.split("@") 67 | 68 | if len(elements) == 1: 69 | path, symbol_name = elements[0], None 70 | elif len(elements) == 2: 71 | path, symbol_name = elements 72 | else: 73 | raise ValueError("@ appears more than once") 74 | 75 | if root_path is None: 76 | content = Path(path).read_text() 77 | else: 78 | content = Path(root_path, path).read_text() 79 | 80 | if symbol_name: 81 | module = parso.parse(content) 82 | named = { 83 | c.name.value: c.get_code() for c in module.children if hasattr(c, "name") 84 | } 85 | content = named[symbol_name] 86 | 87 | if symbols: 88 | content = _get_symbols(content, symbols=symbols) 89 | 90 | if lines: 91 | content_lines = content.splitlines() 92 | start, end = lines[0] - 1, lines[1] 93 | content = "\n".join(content_lines[start:end]) 94 | 95 | suffix = Path(path).suffix 96 | tag = _ext2tag.get(suffix, suffix[1:]) 97 | 98 | comment = "# Content of {}".format(path) 99 | return "{}```{}{}\n{}\n{}\n```{}".format( 100 | header, tag, args, comment, content, footer 101 | ) 102 | 103 | 104 | def _process_node(node): 105 | if hasattr(node, "name"): 106 | return node.name.value 107 | elif node.type == "decorated": 108 | return node.children[-1].name.value 109 | else: 110 | raise RuntimeError 111 | 112 | 113 | def _get_symbols(content, symbols): 114 | """ 115 | Extract symbols from a string with Python code 116 | """ 117 | module = parso.parse(content) 118 | 119 | named = { 120 | _process_node(c): c.get_code().strip() 121 | for c in module.children 122 | if hasattr(c, "name") or c.type == "decorated" 123 | } 124 | 125 | if isinstance(symbols, str): 126 | content_selected = named[symbols] 127 | else: 128 | content_selected = "\n\n\n".join([named[s] for s in symbols]) 129 | 130 | # content_selected contains the requested symbols, let's now subset the 131 | # imports so we only display the ones that are used 132 | 133 | # build a defined-name -> import-statement-code mapping. Note that 134 | # the same code may appear more than once if it defines more than one name 135 | # e.g. from package import a, b, c 136 | imports = [ 137 | { 138 | name.value: import_.get_code().rstrip() 139 | for name in import_.get_defined_names() 140 | } 141 | for import_ in module.iter_imports() 142 | ] 143 | 144 | if imports: 145 | imports = reduce(lambda x, y: {**x, **y}, imports) 146 | else: 147 | imports = {} 148 | 149 | # parse the selected content to get the used symbols 150 | leaf = parso.parse(content_selected).get_first_leaf() 151 | # store used symbols here 152 | names = [] 153 | 154 | while leaf: 155 | if leaf.type == "name": 156 | names.append(leaf.value) 157 | 158 | leaf = leaf.get_next_leaf() 159 | 160 | # iterate over names defined by the imports and get the import statement 161 | # if content_subset uses it 162 | imports_to_use = [] 163 | 164 | for name, import_code in imports.items(): 165 | if name in names: 166 | imports_to_use.append(import_code) 167 | 168 | # remove duplicated elements but keep order, then join 169 | if imports: 170 | imports_to_use = "\n".join(list(dict.fromkeys(imports_to_use))) + "\n\n\n" 171 | else: 172 | imports_to_use = "\n\n" 173 | 174 | return f"{imports_to_use}{content_selected}" 175 | -------------------------------------------------------------------------------- /src/jupyblog/images.py: -------------------------------------------------------------------------------- 1 | from pathlib import PurePosixPath 2 | import re 3 | 4 | # match ![any num of word characters, [space], -, _ or .](filename) 5 | REGEX_IMAGE = re.compile(r"!\[[\ \_\.\-\w]*\]\((.*)\)") 6 | 7 | 8 | def find_images(md): 9 | for match in re.finditer(REGEX_IMAGE, md): 10 | yield match.group(0), match.group(1) 11 | 12 | 13 | # FIXME: remove absolute arg, no longer used 14 | def process_image_links(post, prefix, *, absolute): 15 | for img, img_link in find_images(post): 16 | # ignore paths that are already absolute, they come from 17 | # serialized images 18 | prefix_part = "" if not prefix else prefix + "/" 19 | 20 | if not PurePosixPath(img_link).is_absolute(): 21 | img_link_fixed = ("/" if absolute else "") + prefix_part + img_link 22 | post = post.replace(img, img.replace(img_link, img_link_fixed)) 23 | 24 | return post 25 | 26 | 27 | def get_first_image_path(md): 28 | images = find_images(md) 29 | 30 | try: 31 | _, path = next(images) 32 | except StopIteration: 33 | return None 34 | 35 | return path 36 | 37 | 38 | def add_image_placeholders(md): 39 | """This helps when uploading to medium""" 40 | for img_tag, img_link in find_images(md): 41 | md = md.replace(img_tag, f"**ADD {img_link} HERE**\n{img_tag}") 42 | 43 | return md 44 | -------------------------------------------------------------------------------- /src/jupyblog/md.py: -------------------------------------------------------------------------------- 1 | """ 2 | TODO: 3 | * support for requirements.txt 4 | * create and destroy env 5 | """ 6 | 7 | from copy import copy 8 | from contextlib import contextmanager 9 | from urllib import parse 10 | import logging 11 | from pathlib import Path, PurePosixPath 12 | 13 | import jupytext 14 | import yaml 15 | from jinja2 import Environment, FileSystemLoader, DebugUndefined, Template 16 | import nbformat 17 | 18 | from jupyblog import util, images, models, medium 19 | from jupyblog.execute import ASTExecutor, extract_outputs_from_notebook_cell 20 | from jupyblog.expand import expand 21 | from jupyblog.exceptions import InvalidFrontMatter, InputPostException 22 | from jupyblog.utm import add_utm_to_all_urls, add_utm_to_url 23 | from jupyblog.ast import MarkdownAST, create_md_parser 24 | from jupyblog import __version__ 25 | 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | JUPYBLOG = """\ 30 | jupyblog: 31 | execute_code: false 32 | """ 33 | 34 | REQUIRED = { 35 | "title": "Title is required", 36 | "description": "Description is required for OpenGraph", 37 | "jupyblog": f"jupyblog section is required. Example:\n\n{JUPYBLOG}", 38 | } 39 | 40 | 41 | def validate_metadata(metadata): 42 | # description required for open graph: 43 | # https://gohugo.io/templates/internal/#open-graph 44 | for field in REQUIRED: 45 | if field not in metadata: 46 | reason = REQUIRED[field] 47 | raise InputPostException(f"missing {field!r} in front matter: {reason}") 48 | 49 | 50 | def parse_metadata(md, validate=True): 51 | """Parse markdown metadata""" 52 | start, end = find_metadata_lines(md) 53 | lines = md.splitlines() 54 | metadata = yaml.safe_load("\n".join(lines[start:end])) or {} 55 | 56 | if validate: 57 | validate_metadata(metadata) 58 | 59 | return metadata 60 | 61 | 62 | def find_lines(md, to_find): 63 | """Find lines, returns a mapping of {line: number}""" 64 | to_find = set(to_find) 65 | found = {} 66 | lines = md.splitlines() 67 | 68 | for n, line in enumerate(lines, start=1): 69 | if line in to_find: 70 | found[line] = n 71 | to_find.remove(line) 72 | 73 | if not to_find: 74 | break 75 | 76 | return found 77 | 78 | 79 | def delete_between_line_no(md, to_delete): 80 | """Deletes content between the passed number of lines""" 81 | start, end = to_delete 82 | 83 | if end < start: 84 | raise ValueError( 85 | "Starting line must be lower " f"than end line, got: {to_delete}" 86 | ) 87 | 88 | lines = md.splitlines() 89 | return "\n".join(lines[: start - 1] + lines[end:]) 90 | 91 | 92 | def delete_between_line_content(md, to_delete): 93 | """Deletes content between the passed content""" 94 | if len(to_delete) != 2: 95 | raise ValueError("to_delete must have two " f"elements, got: {len(to_delete)}") 96 | 97 | location = find_lines(md, to_delete) 98 | 99 | start = location[to_delete[0]] 100 | end = location[to_delete[1]] 101 | 102 | return delete_between_line_no(md, (start, end)) 103 | 104 | 105 | def extract_between_line_content(md, marks): 106 | if len(marks) != 2: 107 | raise ValueError("marks must have two " f"elements, got: {len(marks)}") 108 | 109 | location = find_lines(md, marks) 110 | 111 | start = location[marks[0]] 112 | end = location[marks[1]] 113 | 114 | lines = md.splitlines() 115 | return "\n".join(lines[start : end - 1]) 116 | 117 | 118 | def find_metadata_lines(md): 119 | lines = md.splitlines() 120 | idx = [] 121 | 122 | for i, line in enumerate(lines): 123 | if line == "---": 124 | idx.append(i) 125 | 126 | if len(idx) == 2: 127 | break 128 | 129 | if not idx: 130 | raise ValueError("Markdown file does not have YAML front matter") 131 | 132 | if idx[0] != 0: 133 | raise InvalidFrontMatter("metadata not located at the top") 134 | 135 | if len(idx) < 2: 136 | raise InvalidFrontMatter("Closing --- for metadata not found") 137 | 138 | return idx 139 | 140 | 141 | def delete_metadata(md): 142 | try: 143 | _, end = find_metadata_lines(md) 144 | except Exception: 145 | return md 146 | 147 | return "\n".join(md.splitlines()[end + 1 :]) 148 | 149 | 150 | def replace_metadata(md, new_metadata): 151 | lines = md.splitlines() 152 | idx = find_metadata_lines(md) 153 | 154 | lines_new = lines[idx[1] + 1 :] 155 | 156 | new_metadata_text = "---\n{}---\n".format(yaml.dump(new_metadata)) 157 | 158 | return new_metadata_text + "\n".join(lines_new) 159 | 160 | 161 | class GistUploader(MarkdownAST): 162 | def __init__(self, doc): 163 | super().__init__(doc) 164 | 165 | from ghapi.all import GhApi 166 | 167 | self._api = GhApi() 168 | 169 | @staticmethod 170 | def _process_block(block, name): 171 | return dict( 172 | description=None, 173 | files={f'{name}.{block["info"]}': {"content": block["text"]}}, 174 | public=False, 175 | ) 176 | 177 | def _upload_block(self, data): 178 | response = self._api.gists.create(**data) 179 | url = f"https://gist.github.com/{response.id}" 180 | print(url) 181 | return url 182 | 183 | def upload_blocks(self, prefix): 184 | data = [ 185 | self._upload_block(self._process_block(block, name=f"{prefix}-{idx}")) 186 | for idx, block in enumerate(self.iter_blocks()) 187 | ] 188 | 189 | return self.replace_blocks(data) 190 | 191 | 192 | class MarkdownRenderer: 193 | """ 194 | Parameters 195 | ---------- 196 | path_to_out : str or pathlib.Path 197 | Path to the rendered version of this markdown file. Currently, it's only 198 | used to extract the date from the file (to prevent overridding it when 199 | rendering a new version) 200 | 201 | img_dir : str or pathlib.Path 202 | Output path (in the current filesystem) for images. 203 | 204 | img_prefix : str, default=None 205 | Prefix for image tags in markdown file. Note that this can be different 206 | to img_dir depending on the configuration of your blog engine. 207 | 208 | front_matter_template : dict, default=None 209 | Front matter template 210 | 211 | Examples 212 | -------- 213 | >>> mdr = MarkdownRenderer('.') 214 | >>> out = mdr.render('sample.md') 215 | >>> Path('out.md').write_text(out) 216 | """ 217 | 218 | def __init__( 219 | self, 220 | path_to_mds, 221 | img_dir=None, 222 | img_prefix=None, 223 | footer_template=None, 224 | front_matter_template=None, 225 | utm_source=None, 226 | utm_medium=None, 227 | path_to_out=None, 228 | utm_base_urls=None, 229 | ): 230 | self.path = path_to_mds 231 | self.path_to_out = path_to_out 232 | self._img_dir = img_dir 233 | self._img_prefix = img_prefix or "" 234 | self._footer_template = footer_template 235 | self._front_matter_template = front_matter_template 236 | self._utm_source = utm_source 237 | self._utm_medium = utm_medium 238 | self._utm_base_urls = utm_base_urls 239 | self.env = Environment( 240 | loader=FileSystemLoader(path_to_mds), undefined=DebugUndefined 241 | ) 242 | self.parser = create_md_parser() 243 | 244 | def render(self, name, *, include_source_in_footer, metadata=None): 245 | """ 246 | 247 | Parameters 248 | ---------- 249 | metadata : dict, default=None 250 | Metadata to use. If None, it parses metadata from the markdown front matter 251 | """ 252 | path = Path(self.path, name) 253 | 254 | if path.suffix == ".md": 255 | md_raw = path.read_text() 256 | else: 257 | nb = jupytext.read(path) 258 | md_raw = jupytext_writes_to_md(nb) 259 | 260 | medium.check_headers(md_raw) 261 | 262 | md_ast = self.parser(md_raw) 263 | 264 | # TODO: parse_metadata validates the schema, we are now using pydantic for 265 | # models, hence, we can use it for validation and remove this 266 | if metadata is None: 267 | metadata = parse_metadata(md_raw) 268 | 269 | front_matter = models.FrontMatter(**metadata) 270 | 271 | # first render, just expand (expanded snippets are NOT executed) 272 | # also expand urls 273 | # https://github.com/isaacs/github/issues/99#issuecomment-24584307 274 | # https://github.com/isaacs/github/issues/new?title=foo&body=bar 275 | canonical_name = path.resolve().parent.name 276 | url_source = "https://github.com/ploomber/posts/tree/master/{}".format( 277 | canonical_name 278 | ) 279 | url_params = parse.quote("Issue in {}".format(canonical_name)) 280 | URL_ISSUE = "https://github.com/ploomber/posts/issues/new?title={}" 281 | url_issue = URL_ISSUE.format(url_params) 282 | 283 | # extract outputs from notebook with the same name if it exists 284 | path_to_notebook = path.with_suffix(".ipynb") 285 | 286 | if path_to_notebook.exists(): 287 | content = extract_outputs_from_paired_notebook( 288 | path_to_notebook=path_to_notebook, 289 | path_to_md=path, 290 | img_dir=self._img_dir, 291 | canonical_name=canonical_name, 292 | ) 293 | else: 294 | content = md_raw 295 | 296 | if front_matter.jupyblog.allow_expand: 297 | content = expand( 298 | content, 299 | root_path=self.path, 300 | url_source=url_source, 301 | url_issue=url_issue, 302 | args="skip=True", 303 | ) 304 | 305 | logger.debug("After expand:\n%s", content) 306 | 307 | # parse again to get expanded code 308 | if front_matter.jupyblog.execute_code: 309 | md_ast = self.parser(content) 310 | md_out = run_snippets( 311 | md_ast, content, front_matter, self._img_dir, canonical_name 312 | ) 313 | else: 314 | md_out = content 315 | 316 | if self._front_matter_template: 317 | metadata = {**metadata, **self._front_matter_template} 318 | 319 | # if this has been rendered before, use the existing date 320 | if self.path_to_out and Path(self.path_to_out).is_file(): 321 | metadata_rendered = parse_metadata( 322 | Path(self.path_to_out).read_text(), validate=False 323 | ) 324 | date_existing = metadata_rendered.get("date", None) 325 | 326 | if date_existing: 327 | metadata["date"] = date_existing 328 | 329 | # add the jupyblog version 330 | metadata["jupyblog"]["version_jupysql"] = __version__ 331 | 332 | if self._footer_template: 333 | md_out = add_footer( 334 | md_out, 335 | self._footer_template, 336 | metadata["title"], 337 | canonical_name, 338 | include_source_in_footer, 339 | ) 340 | 341 | if self._img_prefix: 342 | prefix = str(PurePosixPath(self._img_prefix, canonical_name)) 343 | else: 344 | prefix = "" 345 | 346 | # FIXME: use img_dir to expand linksq 347 | print("Making img links absolute and adding " "canonical name as prefix...") 348 | md_out = images.process_image_links(md_out, prefix=prefix, absolute=False) 349 | 350 | path = images.get_first_image_path(md_out) 351 | 352 | # add opengraph image only if there isnt one 353 | if path and "images" not in metadata: 354 | metadata["images"] = [path] 355 | 356 | # if there is a marketing URL, add utm tags 357 | marketing_url = metadata.get("marketing", dict()).get("url", None) 358 | 359 | if marketing_url: 360 | metadata["marketing"]["url"] = add_utm_to_url( 361 | marketing_url, 362 | source=canonical_name, 363 | medium=self._utm_medium, 364 | ) 365 | 366 | # TODO: extract title from front matter and put it as H1 header 367 | 368 | md_out = replace_metadata(md_out, metadata) 369 | 370 | # add utm tags, if needed 371 | if self._utm_source and self._utm_medium: 372 | md_out = add_utm_to_all_urls( 373 | md_out, 374 | source=self._utm_source, 375 | medium=self._utm_medium, 376 | campaign=canonical_name, 377 | base_urls=self._utm_base_urls, 378 | ) 379 | 380 | # FIXME: remove canonical name, add it as a parameter 381 | return md_out, canonical_name 382 | 383 | 384 | def add_footer( 385 | md_out, footer_template, title, canonical_name, include_source_in_footer 386 | ): 387 | url_source = "https://github.com/ploomber/posts/tree/master/{}".format( 388 | canonical_name 389 | ) 390 | url_params = parse.quote('Issue in post: "{}"'.format(title)) 391 | url_issue = "https://github.com/ploomber/posts/issues/new?title={}".format( 392 | url_params 393 | ) 394 | 395 | lines = md_out.split("\n") 396 | 397 | if lines[-1] != "\n": 398 | md_out += "\n" 399 | 400 | footer = Template(footer_template).render( 401 | url_source=url_source, 402 | url_issue=url_issue, 403 | include_source_in_footer=include_source_in_footer, 404 | canonical_url="https://ploomber.io/posts/{}".format(canonical_name), 405 | canonical_name=canonical_name, 406 | ) 407 | 408 | md_out += footer 409 | 410 | return md_out 411 | 412 | 413 | def run_snippets(md_ast, content, front_matter, img_dir, canonical_name): 414 | # second render, add output 415 | with ASTExecutor( 416 | front_matter=front_matter, img_dir=img_dir, canonical_name=canonical_name 417 | ) as executor: 418 | # execute 419 | blocks = executor(md_ast) 420 | 421 | # add output tags 422 | out = [block["output"] for block in blocks] 423 | md_out = util.add_output_tags(content, out) 424 | 425 | logger.debug("With output:\n:%s", md_out) 426 | 427 | for block in blocks: 428 | if block.get("hide"): 429 | to_replace = "```{}\n{}```".format(block["info"], block["text"]) 430 | md_out = md_out.replace(to_replace, "") 431 | 432 | for block in blocks: 433 | if block.get("info"): 434 | md_out = md_out.replace(block["info"], block["info"].split(" ")[0]) 435 | 436 | return md_out 437 | 438 | 439 | def extract_outputs_from_paired_notebook( 440 | path_to_notebook, path_to_md, img_dir, canonical_name 441 | ): 442 | """ 443 | Extract outputs from a paired ipynb file and add them as snippets 444 | in the markdown file 445 | """ 446 | nb_ipynb = jupytext.read(path_to_notebook) 447 | nb_md = jupytext.read(path_to_md) 448 | 449 | assert len(nb_ipynb.cells) == len(nb_md.cells) 450 | 451 | to_insert = [] 452 | 453 | for idx, (cell_md, cell_ipynb) in enumerate(zip(nb_md.cells, nb_ipynb.cells)): 454 | if cell_md.cell_type == "code": 455 | to_insert.append((idx, cell_ipynb["outputs"])) 456 | 457 | shift = 0 458 | 459 | for idx, outputs in to_insert: 460 | if outputs: 461 | md_cell = create_markdown_cell_from_outputs( 462 | outputs, 463 | prefix=idx, 464 | serialize_images=True, 465 | img_dir=img_dir, 466 | canonical_name=canonical_name, 467 | ) 468 | nb_md.cells.insert(idx + shift + 1, md_cell) 469 | shift += 1 470 | 471 | reversed = nb_md.cells[::-1] 472 | empty = 0 473 | 474 | for cell in reversed: 475 | if cell.source: 476 | break 477 | else: 478 | empty += 1 479 | 480 | if empty: 481 | nb_md.cells = nb_md.cells[:-empty] 482 | 483 | return jupytext_writes_to_md(nb_md) 484 | 485 | 486 | def create_markdown_cell_from_outputs( 487 | outputs, prefix, serialize_images, img_dir, canonical_name 488 | ): 489 | extracted = extract_outputs_from_notebook_cell( 490 | outputs, prefix, serialize_images, img_dir, canonical_name 491 | ) 492 | source = util.build_output(extracted) 493 | md_cell = nbformat.v4.new_markdown_cell(source=source) 494 | 495 | return md_cell 496 | 497 | 498 | def to_md(path): 499 | """ 500 | A function to convert an ipynb notebook into md (with outputs) that 501 | doesn't require a configuration file 502 | """ 503 | path = Path(path) 504 | 505 | renderer = MarkdownRenderer(path.parent) 506 | out, _ = renderer.render( 507 | path.name, 508 | include_source_in_footer=False, 509 | metadata={"jupyblog": {"execute_code": False}}, 510 | ) 511 | return out 512 | 513 | 514 | def jupytext_writes_to_md(nb): 515 | with remove_sql_magic(): 516 | return jupytext.writes(nb, fmt="md") 517 | 518 | 519 | @contextmanager 520 | def remove_sql_magic(): 521 | backup = copy(jupytext.languages._JUPYTER_LANGUAGES) 522 | 523 | try: 524 | jupytext.languages._JUPYTER_LANGUAGES.remove("sql") 525 | except KeyError: 526 | pass 527 | 528 | try: 529 | yield 530 | finally: 531 | jupytext.languages._JUPYTER_LANGUAGES = backup 532 | -------------------------------------------------------------------------------- /src/jupyblog/medium.py: -------------------------------------------------------------------------------- 1 | from jupyblog.exceptions import InputPostException 2 | 3 | 4 | def apply_language_map(md, mapping): 5 | """Replace code tags""" 6 | mapping = mapping or {} 7 | 8 | for old, new in mapping.items(): 9 | md = md.replace(f"```{old}", f"```{new}") 10 | 11 | return md 12 | 13 | 14 | def find_headers(md): 15 | """ 16 | Find headers in a markdown string, returns an iterator where each 17 | element is a (header text, level) tuple 18 | """ 19 | import mistune 20 | 21 | parser = mistune.create_markdown(renderer=mistune.AstRenderer()) 22 | 23 | for node in parser(md): 24 | if node["type"] == "heading": 25 | node_text = node["children"][0] 26 | 27 | if node_text["type"] == "link": 28 | node_text = node_text["children"][0] 29 | 30 | level = node["level"] 31 | text = node_text["text"] 32 | 33 | if level == 6: 34 | raise ValueError(f"Level 6 headers aren ot supoprted: {text!r}") 35 | 36 | yield text, level 37 | 38 | 39 | def check_headers(md): 40 | """Checks that there are no H1 headers in the markdown string 41 | 42 | Raises 43 | ------ 44 | ValueError 45 | If there is at least one H1 header 46 | 47 | Notes 48 | ----- 49 | Hugo uses H2 headers to build the table of contents (ignores H1), Medium 50 | posts imported from GitHub also ignore H1 headers, post must only contain 51 | H2 and below 52 | """ 53 | h1 = [text for text, level in find_headers(md) if level == 1] 54 | 55 | if h1: 56 | raise InputPostException( 57 | "H1 level headers are not allowed since they " 58 | "are not compatible with Hugo's table of " 59 | f"contents. Replace them with H2 headers: {h1}" 60 | ) 61 | 62 | 63 | # FIXME: not using this anymore. delete 64 | def replace_headers(md): 65 | """ 66 | Transforms headers to one level below. e.g., H1 -> H2 67 | """ 68 | for header, level in find_headers(md): 69 | prefix = "#" * level 70 | prefix_new = "#" * (level + 1) 71 | md = md.replace(f"{prefix} {header}", f"{prefix_new} {header}") 72 | 73 | return md 74 | -------------------------------------------------------------------------------- /src/jupyblog/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from contextlib import contextmanager 4 | from datetime import datetime, timezone 5 | from pathlib import Path 6 | import importlib 7 | 8 | import yaml 9 | from pydantic import BaseModel, Field 10 | from jinja2 import Template, StrictUndefined 11 | 12 | 13 | def _now(): 14 | return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") 15 | 16 | 17 | class Config(BaseModel): 18 | """Schema for jupyblog.yaml 19 | 20 | Parameters 21 | ---------- 22 | root : str 23 | Paths are relative to this directory 24 | 25 | path_to_posts : str 26 | Where to store output .md, relative to root 27 | 28 | path_to_posts : str 29 | Where to store images, relative to root 30 | 31 | prefix_img : str 32 | A prefix to add to all image tags 33 | 34 | language_mapping : dict 35 | Mapping to apply to code chunks 36 | 37 | image_placeholders : bool 38 | Adds a placeholder before each image tag, useful if uploading 39 | to a platform that needs manual image upload (e.g., Medium) 40 | 41 | processor : str 42 | Dotted path with a function to execute to finalize processing, must 43 | return a string with the modified document content, which represents 44 | the .md to store 45 | 46 | postprocessor : str 47 | Dotted path with a function to execute after processing the document 48 | 49 | front_matter_template : str 50 | Relative path to a YAML to use as default values for the post's 51 | front matter. If None, it uses a default front matter. The template may 52 | use {{now}} and {{name}} placeholders, which render to the current 53 | datetime and post name, respectively. 54 | 55 | utm_source : str 56 | The utm_source tag to add to all URLs 57 | 58 | utm_medium : str 59 | The utm_source tag to add to all URLs 60 | 61 | utm_base_urls : list 62 | List of strings with URLs where UTM tags should be added. Matches substrings. 63 | """ 64 | 65 | root: str 66 | path_to_posts: str 67 | path_to_static: str 68 | prefix_img: str = "" 69 | language_mapping: dict = None 70 | image_placeholders: bool = False 71 | processor: str = None 72 | postprocessor: str = None 73 | front_matter_template: str = None 74 | footer: str = None 75 | utm_source: str = None 76 | utm_medium: str = None 77 | utm_base_urls: list = None 78 | 79 | def path_to_posts_abs(self): 80 | return Path(self.root, self.path_to_posts) 81 | 82 | def path_to_static_abs(self): 83 | return Path(self.root, self.path_to_static) 84 | 85 | def read_footer_template(self): 86 | if self.footer: 87 | path = Path(self.root, self.footer) 88 | 89 | if path.exists(): 90 | return path.read_text() 91 | 92 | def load_processor(self): 93 | if self.processor: 94 | return self._load_dotted_path(self.processor) 95 | 96 | def load_postprocessor(self): 97 | if self.postprocessor: 98 | with add_to_sys_path(self.root, chdir=False): 99 | return self._load_dotted_path(self.postprocessor) 100 | 101 | def load_front_matter_template(self, name): 102 | if self.front_matter_template: 103 | path = Path(self.root, self.front_matter_template) 104 | 105 | if path.exists(): 106 | text = path.read_text() 107 | 108 | now = _now() 109 | rendered = Template(text, undefined=StrictUndefined).render( 110 | now=now, 111 | name=name, 112 | env=os.environ, 113 | ) 114 | front_matter = yaml.safe_load(rendered) 115 | else: 116 | front_matter = {} 117 | 118 | return front_matter 119 | else: 120 | return dict() 121 | 122 | @staticmethod 123 | def _load_dotted_path(dotted_path): 124 | mod, _, attr = dotted_path.rpartition(".") 125 | 126 | return getattr(importlib.import_module(mod), attr) 127 | 128 | 129 | class Settings(BaseModel): 130 | """Schema for jupyblog section in .md front matter 131 | 132 | Parameters 133 | ---------- 134 | serialize_images : bool, default=False 135 | Saves images to external files (`serialized/` directory), otherwise 136 | embeds them in the same file as base64 strings. 137 | 138 | allow_expand : bool, default=False 139 | If True, it allows the use of `'{{expand("file.py")'}}` to include 140 | the content of a file or `'{{expand("file.py@symbol")'}}` to replace 141 | with a specific symbol in such file. 142 | 143 | execute_code : bool, default=True 144 | Execute code snippets. 145 | """ 146 | 147 | serialize_images: bool = False 148 | allow_expand: bool = False 149 | execute_code: bool = True 150 | 151 | 152 | class FrontMatter(BaseModel): 153 | """ 154 | Schema for .md front matter 155 | """ 156 | 157 | jupyblog: Settings = Field(default_factory=Settings) 158 | 159 | 160 | @contextmanager 161 | def add_to_sys_path(path, chdir): 162 | cwd_old = os.getcwd() 163 | 164 | if path is not None: 165 | path = os.path.abspath(path) 166 | sys.path.insert(0, path) 167 | 168 | if chdir: 169 | os.chdir(path) 170 | 171 | try: 172 | yield 173 | finally: 174 | if path is not None: 175 | sys.path.remove(path) 176 | os.chdir(cwd_old) 177 | -------------------------------------------------------------------------------- /src/jupyblog/postprocess.py: -------------------------------------------------------------------------------- 1 | from jupyblog import md 2 | 3 | 4 | def upload_to_github(doc, name, config, front_matter): 5 | """ 6 | Upload code snippets to github 7 | 8 | Parameters 9 | ---------- 10 | doc : str 11 | .md document content 12 | 13 | name : str 14 | Post name 15 | """ 16 | gu = md.GistUploader(doc) 17 | doc = gu.upload_blocks(prefix=name) 18 | 19 | data = dict( 20 | description=f"Blog post: {name}", 21 | files={f"{name}.md": {"content": doc}}, 22 | public=False, 23 | ) 24 | 25 | response = gu._api.gists.create(**data) 26 | return f"https://gist.github.com/{response.id}" 27 | -------------------------------------------------------------------------------- /src/jupyblog/util.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | from glob import glob 4 | from jinja2 import Template 5 | from itertools import chain 6 | 7 | 8 | def find_endings(md): 9 | n_all = [n for n, l in enumerate(md.splitlines()) if l.startswith("```")] 10 | endings = [n for i, n in enumerate(n_all) if i % 2] 11 | return endings 12 | 13 | 14 | def build_output(parts): 15 | # remove trailing and leading whitespace, remove empty content 16 | parts = [(kind, content.rstrip().lstrip()) for kind, content in parts if content] 17 | 18 | t = Template( 19 | """ 20 | {% for kind, content in parts %} 21 | **Console output ({{loop.index}}/{{total}}):** 22 | {% if kind == 'text/plain' %} 23 | ```txt 24 | {{content}} 25 | ``` 26 | {% else %} 27 | {{content}}{% endif %}{% endfor %}""" 28 | ) 29 | 30 | return t.render(parts=parts, total=len(parts)) 31 | 32 | 33 | def add_output_tags(md, outputs): 34 | endings = find_endings(md) 35 | lines = md.splitlines() 36 | 37 | shifts = 0 38 | 39 | for out, end in zip(outputs, endings): 40 | if out is not None: 41 | # add trailing \n if there is not any 42 | # out = out if out[-1] == '\n' else out + '\n' 43 | # remove leading \n if any, we will ad one 44 | # out = out if out[0] != '\n' else out[1:] 45 | 46 | to_insert = build_output(out) 47 | lines.insert(end + 1 + shifts, to_insert) 48 | shifts += 1 49 | 50 | md_new = "\n".join(lines) 51 | 52 | return md_new 53 | 54 | 55 | def copy_all_images(src, target, dir_name): 56 | """ 57 | Copy all .png, .gif, and .webm files in src to target inside a folder with the 58 | passed name 59 | """ 60 | pngs = glob(str(Path(src, "**", "*.png")), recursive=True) 61 | gifs = glob(str(Path(src, "**", "*.gif")), recursive=True) 62 | videos = glob(str(Path(src, "**", "*.webm")), recursive=True) 63 | 64 | for img in chain(pngs, gifs, videos): 65 | # target location: {target}/{dir-name}/{original-relative-path} 66 | rel_path = str(Path(img).relative_to(src)) 67 | target_file = Path(target, dir_name, rel_path) 68 | target_file.parent.mkdir(parents=True, exist_ok=True) 69 | print("Copying %s to %s" % (img, target_file)) 70 | 71 | shutil.copy(img, target_file) 72 | -------------------------------------------------------------------------------- /src/jupyblog/utm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Process URLs in a markdown file 3 | """ 4 | 5 | from urllib.parse import urlparse, urlencode, parse_qsl 6 | from pathlib import PurePosixPath, Path 7 | 8 | import click 9 | 10 | from jupyblog.ast import MarkdownAST 11 | 12 | 13 | def find_urls(text): 14 | """Find all urls in a text""" 15 | ast = MarkdownAST(text) 16 | return list(ast.iter_links()) 17 | 18 | 19 | def add_utm_to_url(url, source, medium, campaign=None): 20 | if isinstance(url, str): 21 | parsed = urlparse(url) 22 | else: 23 | parsed = url 24 | 25 | current_params = dict(parse_qsl(parsed.query)) 26 | utm = {"utm_source": source, "utm_medium": medium} 27 | 28 | if campaign: 29 | utm["utm_campaign"] = campaign 30 | 31 | parsed = parsed._replace(query=urlencode({**current_params, **utm})) 32 | 33 | return parsed.geturl() 34 | 35 | 36 | def add_utm_to_all_urls(text, source, medium, campaign, base_urls=None): 37 | """Adds utms to urls found in text, ignores image resources""" 38 | out = text 39 | 40 | urls = find_urls(text) 41 | 42 | if base_urls: 43 | urls = [url for url in urls if any(base_url in url for base_url in base_urls)] 44 | 45 | urls = [urlparse(url) for url in urls] 46 | 47 | # ignore static resources 48 | urls = [url for url in urls if not is_image(url.path)] 49 | 50 | mapping = { 51 | url.geturl(): add_utm_to_url(url, source, medium, campaign) for url in urls 52 | } 53 | 54 | for original, new in mapping.items(): 55 | out = out.replace(f"({original})", f"({new})") 56 | 57 | return out 58 | 59 | 60 | def is_image(image_url_path): 61 | path = PurePosixPath(image_url_path).name 62 | return any( 63 | path.endswith(f".{suffix}") 64 | for suffix in {"png", "jpg", "jpeg", "svg", "webp", "gif"} 65 | ) 66 | 67 | 68 | @click.command() 69 | @click.option("-t", "--template", type=click.Choice(["reddit"], case_sensitive=False)) 70 | @click.option("-f", "--filename", type=click.Path(exists=True), default="file.txt") 71 | def cli(template, filename): 72 | """Add UTM codes to all URLs in the pasted text 73 | 74 | Use the reddit template: 75 | 76 | $ python -m jupyblog.utm -f file.txt -t reddit 77 | 78 | Enter all UTM parameters manually: 79 | 80 | $ python -m jupyblog.utm -f file.txt 81 | """ 82 | templates = { 83 | "reddit": { 84 | "source": "reddit", 85 | "medium": "social", 86 | } 87 | } 88 | 89 | text = Path(filename).read_text() 90 | 91 | if template: 92 | source = templates[template]["source"] 93 | medium = templates[template]["medium"] 94 | else: 95 | source = click.prompt("Enter source", type=str) 96 | medium = click.prompt("Enter medium", type=str) 97 | 98 | campaign = click.prompt("Enter campaign", type=str) 99 | click.echo(add_utm_to_all_urls(text, source, medium, campaign)) 100 | 101 | 102 | if __name__ == "__main__": 103 | cli() 104 | -------------------------------------------------------------------------------- /tests/assets/expand-placeholder/another.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: some awesome post 3 | description: something 4 | jupyblog: 5 | allow_expand: True 6 | --- 7 | 8 | 9 | {{expand('functions.py@fn')}} 10 | -------------------------------------------------------------------------------- /tests/assets/expand-placeholder/content/posts/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/expand-placeholder/content/posts/.empty -------------------------------------------------------------------------------- /tests/assets/expand-placeholder/functions.py: -------------------------------------------------------------------------------- 1 | def some_function(): 2 | pass 3 | 4 | 5 | def fn(x): 6 | return x 7 | -------------------------------------------------------------------------------- /tests/assets/expand-placeholder/jupyblog.yaml: -------------------------------------------------------------------------------- 1 | path_to_posts: content/posts 2 | path_to_static: static -------------------------------------------------------------------------------- /tests/assets/expand-placeholder/post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: some awesome post 3 | description: something 4 | jupyblog: 5 | allow_expand: True 6 | --- 7 | 8 | 9 | {{expand('script.py')}} 10 | -------------------------------------------------------------------------------- /tests/assets/expand-placeholder/script.py: -------------------------------------------------------------------------------- 1 | 1 + 1 2 | -------------------------------------------------------------------------------- /tests/assets/expand-placeholder/static/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/expand-placeholder/static/.empty -------------------------------------------------------------------------------- /tests/assets/image-nested/content/posts/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/image-nested/content/posts/.empty -------------------------------------------------------------------------------- /tests/assets/image-nested/images/jupyter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/image-nested/images/jupyter.png -------------------------------------------------------------------------------- /tests/assets/image-nested/jupyblog.yaml: -------------------------------------------------------------------------------- 1 | path_to_posts: content/posts 2 | path_to_static: static -------------------------------------------------------------------------------- /tests/assets/image-nested/post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: some awesome post 3 | description: something 4 | jupyblog: 5 | execute_code: false 6 | --- 7 | 8 | some content 9 | 10 | ![jupyter](images/jupyter.png) -------------------------------------------------------------------------------- /tests/assets/image-nested/static/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/image-nested/static/.empty -------------------------------------------------------------------------------- /tests/assets/image/content/posts/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/image/content/posts/.empty -------------------------------------------------------------------------------- /tests/assets/image/jupyblog.yaml: -------------------------------------------------------------------------------- 1 | path_to_posts: content/posts 2 | path_to_static: static -------------------------------------------------------------------------------- /tests/assets/image/jupyter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/image/jupyter.png -------------------------------------------------------------------------------- /tests/assets/image/post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: some awesome post 3 | description: something 4 | jupyblog: 5 | execute_code: False 6 | --- 7 | 8 | some content 9 | 10 | ![jupyter](jupyter.png) -------------------------------------------------------------------------------- /tests/assets/image/static/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/image/static/.empty -------------------------------------------------------------------------------- /tests/assets/sample.md: -------------------------------------------------------------------------------- 1 | --- 2 | key: 1 3 | root_path: /Users/Edu/dev/posts/self-documented-data 4 | --- 5 | 6 | # header 7 | 8 | ```python hide=true 9 | import os 10 | os.chdir('/Users/Edu/dev/posts/self-documented-data') 11 | ``` 12 | 13 | Sample code: 14 | 15 | ```python 16 | {{ expand('main.py') }} 17 | ``` 18 | 19 | 20 | ```python 21 | 1 + 1 22 | ``` 23 | 24 | 25 | ```python 26 | 2 + 2 27 | ``` 28 | 29 | ```python 30 | 3 + 3 31 | ``` -------------------------------------------------------------------------------- /tests/assets/sample_post/content/posts/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/sample_post/content/posts/.empty -------------------------------------------------------------------------------- /tests/assets/sample_post/jupyblog.yaml: -------------------------------------------------------------------------------- 1 | path_to_posts: content/posts 2 | path_to_static: static -------------------------------------------------------------------------------- /tests/assets/sample_post/post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: some awesome post 3 | description: something 4 | jupyblog: 5 | execute_code: false 6 | --- 7 | 8 | some content -------------------------------------------------------------------------------- /tests/assets/sample_post/static/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/sample_post/static/.empty -------------------------------------------------------------------------------- /tests/assets/with_py_code/content/posts/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/with_py_code/content/posts/.empty -------------------------------------------------------------------------------- /tests/assets/with_py_code/jupyblog.yaml: -------------------------------------------------------------------------------- 1 | path_to_posts: content/posts 2 | path_to_static: static -------------------------------------------------------------------------------- /tests/assets/with_py_code/post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: some awesome post 3 | description: something 4 | jupyblog: 5 | execute_code: False 6 | --- 7 | 8 | some content 9 | 10 | ```python 11 | 1 + 1 12 | ``` -------------------------------------------------------------------------------- /tests/assets/with_py_code/static/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupyblog/0c2f6d1b18f818c602ceac2130ffd5cb3e4a09e6/tests/assets/with_py_code/static/.empty -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from copy import copy 3 | import shutil 4 | import os 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | 10 | def _path_to_tests(): 11 | return Path(__file__).absolute().parent 12 | 13 | 14 | def _copy_from_assets(tmp_path, name): 15 | relative_path_project = str(Path("assets", name)) 16 | tmp = Path(tmp_path, relative_path_project) 17 | sample_post = _path_to_tests() / relative_path_project 18 | shutil.copytree(str(sample_post), str(tmp)) 19 | return tmp 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | def path_to_tests(): 24 | return _path_to_tests() 25 | 26 | 27 | @pytest.fixture 28 | def tmp_empty(tmp_path): 29 | """ 30 | Create temporary path using pytest native fixture, 31 | them move it, yield, and restore the original path 32 | """ 33 | old = os.getcwd() 34 | os.chdir(str(tmp_path)) 35 | yield str(Path(tmp_path).resolve()) 36 | os.chdir(old) 37 | 38 | 39 | @pytest.fixture 40 | def tmp_sample_post(tmp_path): 41 | tmp = _copy_from_assets(tmp_path, "sample_post") 42 | old = os.getcwd() 43 | os.chdir(str(tmp)) 44 | yield tmp 45 | os.chdir(old) 46 | 47 | 48 | @pytest.fixture 49 | def tmp_with_py_code(tmp_path): 50 | tmp = _copy_from_assets(tmp_path, "with_py_code") 51 | old = os.getcwd() 52 | os.chdir(str(tmp)) 53 | yield tmp 54 | os.chdir(old) 55 | 56 | 57 | @pytest.fixture 58 | def tmp_image(tmp_path): 59 | tmp = _copy_from_assets(tmp_path, "image") 60 | old = os.getcwd() 61 | os.chdir(str(tmp)) 62 | yield tmp 63 | os.chdir(old) 64 | 65 | 66 | @pytest.fixture 67 | def tmp_image_nested(tmp_path): 68 | tmp = _copy_from_assets(tmp_path, "image-nested") 69 | old = os.getcwd() 70 | os.chdir(str(tmp)) 71 | yield tmp 72 | os.chdir(old) 73 | 74 | 75 | @pytest.fixture 76 | def tmp_expand_placeholder(tmp_path): 77 | tmp = _copy_from_assets(tmp_path, "expand-placeholder") 78 | old = os.getcwd() 79 | os.chdir(str(tmp)) 80 | yield tmp 81 | os.chdir(old) 82 | 83 | 84 | @pytest.fixture 85 | def add_current_to_sys_path(): 86 | old = copy(sys.path) 87 | sys.path.insert(0, os.path.abspath(".")) 88 | yield sys.path 89 | sys.path = old 90 | 91 | 92 | @pytest.fixture 93 | def no_sys_modules_cache(): 94 | """ 95 | Removes modules from sys.modules that didn't exist before the test 96 | """ 97 | mods = set(sys.modules) 98 | 99 | yield 100 | 101 | current = set(sys.modules) 102 | 103 | to_remove = current - mods 104 | 105 | for a_module in to_remove: 106 | del sys.modules[a_module] 107 | 108 | 109 | @pytest.fixture 110 | def tmp_imports(add_current_to_sys_path, no_sys_modules_cache): 111 | """ 112 | Adds current directory to sys.path and deletes everything imported during 113 | test execution upon exit 114 | """ 115 | yield 116 | -------------------------------------------------------------------------------- /tests/test_ast.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jupyblog.ast import MarkdownAST 4 | 5 | simple = """ 6 | [Some link](https://ploomber.io) 7 | """ 8 | 9 | multiple = """ 10 | # Heading 11 | 12 | [Some link](https://ploomber.io) 13 | 14 | ## Another heading 15 | 16 | This is some text [Another link](https://github.com) 17 | """ 18 | 19 | images_and_raw_urls = """ 20 | 21 | [Some link](https://ploomber.io) 22 | 23 | https://google.com 24 | 25 | ![some-image](https://ploomber.io/image.png) 26 | """ 27 | 28 | code_fence = """ 29 | 30 | ```sh 31 | curl -O https://ploomber.io/something.html 32 | ``` 33 | 34 | """ 35 | 36 | 37 | @pytest.mark.parametrize( 38 | "doc, expected", 39 | [ 40 | [ 41 | simple, 42 | ["https://ploomber.io"], 43 | ], 44 | [ 45 | multiple, 46 | ["https://ploomber.io", "https://github.com"], 47 | ], 48 | [ 49 | images_and_raw_urls, 50 | ["https://ploomber.io"], 51 | ], 52 | [ 53 | code_fence, 54 | [], 55 | ], 56 | ], 57 | ) 58 | def test_iter_links(doc, expected): 59 | ast = MarkdownAST(doc) 60 | assert list(ast.iter_links()) == expected 61 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import yaml 5 | from click.testing import CliRunner 6 | import pytest 7 | import nbformat 8 | from ploomber_engine import execute_notebook 9 | 10 | from jupyblog.cli import cli 11 | from jupyblog import cli as cli_module 12 | from jupyblog.md import parse_metadata 13 | from jupyblog import models 14 | from jupyblog import __version__ 15 | 16 | # TODO: mock test that render passes the right parameters to _render 17 | 18 | 19 | def _create_post(post_name, content): 20 | parent = Path(post_name) 21 | parent.mkdir() 22 | (parent / "post.md").write_text(content) 23 | os.chdir(parent) 24 | 25 | 26 | def test_expand(tmp_empty): 27 | Path("file.py").write_text("1 + 1") 28 | Path("file.md").write_text('{{expand("file.py")}}') 29 | 30 | runner = CliRunner() 31 | result = runner.invoke( 32 | cli, ["expand", "file.md", "--output", "out.md"], catch_exceptions=False 33 | ) 34 | 35 | content = Path("out.md").read_text() 36 | 37 | assert not result.exit_code 38 | assert content == "```python\n# Content of file.py\n1 + 1\n```" 39 | 40 | 41 | def test_sample_post(tmp_sample_post): 42 | runner = CliRunner() 43 | result = runner.invoke(cli, ["render", "--local"], catch_exceptions=False) 44 | 45 | content = Path("output", "sample_post.md").read_text() 46 | metadata = parse_metadata(content) 47 | 48 | assert not result.exit_code 49 | assert content 50 | assert metadata["title"] == "some awesome post" 51 | 52 | 53 | def test_with_python_code(tmp_with_py_code): 54 | runner = CliRunner() 55 | result = runner.invoke(cli, ["render", "--local"], catch_exceptions=False) 56 | 57 | content = Path("output", "with_py_code.md").read_text() 58 | metadata = parse_metadata(content) 59 | 60 | assert not result.exit_code 61 | assert content 62 | assert metadata["title"] == "some awesome post" 63 | 64 | 65 | def test_image(tmp_image): 66 | runner = CliRunner() 67 | result = runner.invoke(cli, ["render"], catch_exceptions=False) 68 | 69 | content = Path("content", "posts", "image.md").read_text() 70 | metadata = parse_metadata(content) 71 | 72 | assert not result.exit_code 73 | assert "![jupyter](jupyter.png)" in content 74 | assert Path("static", "image", "jupyter.png").is_file() 75 | assert Path("jupyter.png").is_file() 76 | assert metadata["title"] == "some awesome post" 77 | assert metadata["images"][0] == "jupyter.png" 78 | 79 | 80 | def test_image_nested(tmp_image_nested): 81 | runner = CliRunner() 82 | result = runner.invoke(cli, ["render"], catch_exceptions=False) 83 | 84 | content = Path("content", "posts", "image-nested.md").read_text() 85 | metadata = parse_metadata(content) 86 | 87 | assert not result.exit_code 88 | assert "![jupyter](images/jupyter.png)" in content 89 | assert Path("static", "image-nested", "images", "jupyter.png").is_file() 90 | assert metadata["title"] == "some awesome post" 91 | assert metadata["images"][0] == "images/jupyter.png" 92 | 93 | 94 | def test_image_medium(tmp_image): 95 | runner = CliRunner() 96 | result = runner.invoke(cli, ["render"], catch_exceptions=False) 97 | 98 | content = Path("content", "posts", "image.md").read_text() 99 | metadata = parse_metadata(content) 100 | 101 | assert not result.exit_code 102 | assert "![jupyter](jupyter.png)" in content 103 | assert Path("static", "image", "jupyter.png").is_file() 104 | assert metadata["title"] == "some awesome post" 105 | 106 | 107 | simple_with_image = """\ 108 | --- 109 | title: title 110 | description: description 111 | jupyblog: 112 | execute_code: false 113 | --- 114 | 115 | ![image](my-image.png) 116 | """ 117 | 118 | 119 | def test_local_config(tmp_empty): 120 | _create_post("some-post", simple_with_image) 121 | 122 | cli_module._render(local=True) 123 | 124 | content = Path("output", "some-post.md").read_text() 125 | 126 | assert "![image](my-image.png)" in content 127 | 128 | 129 | def test_language_mapping(tmp_empty): 130 | Path("jupyblog.yaml").write_text( 131 | """ 132 | path_to_posts: output 133 | path_to_static: static 134 | language_mapping: 135 | python: py 136 | bash: sh 137 | """ 138 | ) 139 | 140 | Path("output").mkdir() 141 | Path("static").mkdir() 142 | 143 | _create_post( 144 | "my-post", 145 | """\ 146 | --- 147 | title: title 148 | description: description 149 | jupyblog: 150 | execute_code: false 151 | --- 152 | 153 | ```python 154 | 1 + 1 155 | ``` 156 | 157 | ```bash 158 | cp file another 159 | ``` 160 | """, 161 | ) 162 | 163 | cli_module._render(local=False) 164 | 165 | content = Path("..", "output", "my-post.md").read_text() 166 | 167 | assert "```py\n" in content 168 | assert "```sh\n" in content 169 | 170 | 171 | @pytest.mark.parametrize( 172 | "footer_template, expected", 173 | [ 174 | ["my footer", "my footer"], 175 | ["canonical name: {{canonical_name}}", "canonical name: my-post"], 176 | ], 177 | ) 178 | def test_footer_template(tmp_empty, footer_template, expected): 179 | Path("jupyblog.yaml").write_text( 180 | """ 181 | path_to_posts: output 182 | path_to_static: static 183 | footer: jupyblog-footer.md 184 | """ 185 | ) 186 | 187 | Path("jupyblog-footer.md").write_text(footer_template) 188 | 189 | Path("output").mkdir() 190 | Path("static").mkdir() 191 | 192 | _create_post( 193 | "my-post", 194 | """\ 195 | --- 196 | title: title 197 | description: description 198 | jupyblog: 199 | execute_code: false 200 | --- 201 | """, 202 | ) 203 | 204 | cli_module._render(local=False) 205 | 206 | content = Path("..", "output", "my-post.md").read_text() 207 | 208 | assert expected in content 209 | 210 | 211 | def test_config(tmp_empty): 212 | Path("jupyblog.yaml").write_text( 213 | """ 214 | path_to_posts: output 215 | path_to_static: static 216 | """ 217 | ) 218 | 219 | Path("jupyblog.another.yaml").write_text( 220 | """ 221 | path_to_posts: posts 222 | path_to_static: static 223 | """ 224 | ) 225 | 226 | Path("posts").mkdir() 227 | Path("static").mkdir() 228 | 229 | _create_post( 230 | "my-post", 231 | """\ 232 | --- 233 | title: title 234 | description: description 235 | jupyblog: 236 | execute_code: false 237 | --- 238 | """, 239 | ) 240 | 241 | cli_module._render(local=False, cfg="jupyblog.another.yaml") 242 | 243 | assert Path("..", "posts", "my-post.md").read_text() 244 | 245 | 246 | def test_add_image_placeholders(tmp_image): 247 | Path("jupyblog.yaml").write_text( 248 | """ 249 | path_to_posts: content/posts 250 | path_to_static: static 251 | image_placeholders: true 252 | """ 253 | ) 254 | 255 | cli_module._render(local=False) 256 | 257 | content = Path("content", "posts", "image.md").read_text() 258 | assert "**ADD jupyter.png HERE**" in content 259 | 260 | 261 | def test_processor(tmp_image, tmp_imports): 262 | Path("jupyblog.yaml").write_text( 263 | """ 264 | path_to_posts: content/posts 265 | path_to_static: static 266 | processor: processor.add_footer 267 | """ 268 | ) 269 | 270 | Path("processor.py").write_text( 271 | """ 272 | def add_footer(doc, name): 273 | return f'{doc}\\nmy name is {name}' 274 | """ 275 | ) 276 | 277 | cli_module._render(local=False) 278 | 279 | content = Path("content", "posts", "image.md").read_text() 280 | assert "my name is image" in content.splitlines()[-1] 281 | 282 | 283 | def test_front_matter_template(tmp_sample_post, monkeypatch): 284 | monkeypatch.setattr(models, "_now", lambda: "now") 285 | monkeypatch.setenv("author_name", "Eduardo Blancas") 286 | 287 | fm = yaml.safe_load(Path("jupyblog.yaml").read_text()) 288 | fm["front_matter_template"] = "template.yaml" 289 | 290 | template = { 291 | "date": "{{now}}", 292 | "author": "{{env.author_name}}", 293 | "image": "{{name}}.png", 294 | } 295 | Path("template.yaml").write_text(yaml.dump(template)) 296 | Path("jupyblog.yaml").write_text(yaml.dump(fm)) 297 | 298 | runner = CliRunner() 299 | result = runner.invoke(cli, ["render"], catch_exceptions=False) 300 | 301 | content = Path("content", "posts", "sample_post.md").read_text() 302 | metadata = parse_metadata(content) 303 | 304 | assert not result.exit_code 305 | assert metadata == { 306 | "author": "Eduardo Blancas", 307 | "date": "now", 308 | "description": "something", 309 | "jupyblog": {"execute_code": False, "version_jupysql": __version__}, 310 | "title": "some awesome post", 311 | "image": "sample_post.png", 312 | } 313 | 314 | 315 | def test_front_matter_template_error_missing_env(tmp_sample_post, monkeypatch): 316 | fm = yaml.safe_load(Path("jupyblog.yaml").read_text()) 317 | fm["front_matter_template"] = "template.yaml" 318 | 319 | template = { 320 | "date": "{{now}}", 321 | "author": "{{env.author_name}}", 322 | "image": "{{name}}.png", 323 | } 324 | Path("template.yaml").write_text(yaml.dump(template)) 325 | Path("jupyblog.yaml").write_text(yaml.dump(fm)) 326 | 327 | runner = CliRunner() 328 | result = runner.invoke(cli, ["render"], catch_exceptions=True) 329 | 330 | assert result.exit_code 331 | assert "has no attribute" in str(result.exception) 332 | 333 | 334 | def test_utm_tags(tmp_sample_post): 335 | Path("jupyblog.yaml").write_text( 336 | """ 337 | path_to_posts: output 338 | path_to_static: static 339 | utm_source: ploomber 340 | utm_medium: blog 341 | """ 342 | ) 343 | 344 | Path("post.md").write_text( 345 | """\ 346 | --- 347 | title: title 348 | description: description 349 | jupyblog: 350 | execute_code: false 351 | --- 352 | 353 | [some-link](https://ploomber.io/stuff) 354 | """ 355 | ) 356 | 357 | runner = CliRunner() 358 | result = runner.invoke(cli, ["render"], catch_exceptions=False) 359 | 360 | assert not result.exit_code 361 | expected = ( 362 | "[some-link]" 363 | "(https://ploomber.io/stuff" 364 | "?utm_source=ploomber&utm_medium=blog&utm_campaign=sample_post)" 365 | ) 366 | text = Path("output", "sample_post.md").read_text() 367 | assert expected in text 368 | 369 | 370 | def test_convert_ipynb(tmp_sample_post): 371 | Path("post.md").unlink() 372 | 373 | front_matter = """\ 374 | --- 375 | title: title 376 | description: description 377 | jupyblog: 378 | execute_code: false 379 | ---\ 380 | """ 381 | 382 | nb = nbformat.v4.new_notebook() 383 | 384 | cells = ["1 + 1", "2 + 2"] 385 | nb.cells = [nbformat.v4.new_raw_cell(source=front_matter)] + [ 386 | nbformat.v4.new_code_cell(source=cell) for cell in cells 387 | ] 388 | 389 | execute_notebook( 390 | nb, 391 | output_path="post.ipynb", 392 | ) 393 | 394 | runner = CliRunner() 395 | result = runner.invoke(cli, ["render"], catch_exceptions=False) 396 | 397 | assert not result.exit_code 398 | out = Path("content", "posts", "sample_post.md").read_text() 399 | assert "txt\n4\n```" in out 400 | assert "Console output" in out 401 | 402 | 403 | # FIXME: test postprocessor 404 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import yaml 4 | import pytest 5 | 6 | from jupyblog.config import get_config 7 | 8 | 9 | @pytest.fixture 10 | def default_config(): 11 | cfg = { 12 | "path_to_posts": "content/posts", 13 | "path_to_static": "static", 14 | } 15 | 16 | Path("jupyblog.yaml").write_text(yaml.dump(cfg)) 17 | 18 | 19 | def test_missing_config(tmp_empty): 20 | with pytest.raises(FileNotFoundError): 21 | get_config() 22 | 23 | 24 | def test_creates_directories(tmp_empty, default_config): 25 | get_config() 26 | 27 | assert Path("static").is_dir() 28 | assert Path("content/posts").is_dir() 29 | 30 | 31 | def test_get_config(tmp_empty, default_config): 32 | Path("static").mkdir(parents=True) 33 | Path("content/posts").mkdir(parents=True) 34 | 35 | cfg = get_config() 36 | assert cfg.path_to_posts_abs() == Path("content", "posts").resolve() 37 | assert cfg.path_to_static_abs() == Path("static").resolve() 38 | -------------------------------------------------------------------------------- /tests/test_execute.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jupyblog.execute import JupyterSession 4 | 5 | 6 | @pytest.fixture 7 | def session(): 8 | s = JupyterSession() 9 | yield s 10 | del s 11 | 12 | 13 | pandas_output = ( 14 | "
\n\n\n \n ' 19 | '\n \n \n' 20 | " \n \n \n \n \n " 21 | "\n \n \n \n \n " 22 | "\n \n \n \n \n " 23 | "\n
x
00
11
22
\n
" 24 | ) 25 | 26 | 27 | @pytest.mark.parametrize( 28 | "code, output", 29 | [ 30 | ["print(1); print(1)", ("text/plain", "1\n1\n")], 31 | ["1 + 1", ("text/plain", "2")], 32 | ["print(1 + 1)", ("text/plain", "2\n")], 33 | [ 34 | 'from IPython.display import HTML; HTML("
hi
")', 35 | ("text/html", "
hi
"), 36 | ], 37 | [ 38 | 'import pandas as pd; pd.DataFrame({"x": range(3)})', 39 | ("text/html", pandas_output), 40 | ], 41 | ], 42 | ) 43 | def test_jupyter_session(session, code, output): 44 | assert session.execute(code) == [output] 45 | 46 | 47 | def test_jupyter_session_traceback(session): 48 | out = session.execute('raise ValueError("message")')[0][1] 49 | assert "Traceback (most recent call last)" in out 50 | assert "ValueError: message" in out 51 | -------------------------------------------------------------------------------- /tests/test_expand.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from jupyblog.expand import expand 6 | 7 | 8 | @pytest.fixture 9 | def create_files(): 10 | Path("sum.py").write_text( 11 | """ 12 | def sum(a, b): 13 | return a + b 14 | """ 15 | ) 16 | 17 | Path("operations.py").write_text( 18 | """ 19 | def multiply(a, b): 20 | return a * b 21 | 22 | def divide(a, b): 23 | return a / b 24 | """ 25 | ) 26 | 27 | Path("env.yaml").write_text( 28 | """ 29 | key: value 30 | """ 31 | ) 32 | 33 | Path("with_imports.py").write_text( 34 | """ 35 | import math 36 | import json 37 | 38 | def uses_math(x): 39 | return math.something() 40 | 41 | def uses_both(x): 42 | a = math.something() 43 | b = json.another() 44 | return a, b 45 | """ 46 | ) 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "content, expected", 51 | [ 52 | [ 53 | "{{expand('sum.py')}}", 54 | ( 55 | "```python\n# Content of sum.py\n\n" 56 | "def sum(a, b):\n return a + b\n\n```" 57 | ), 58 | ], 59 | [ 60 | "{{expand('operations.py@multiply')}}", 61 | ( 62 | "```python\n# Content of operations.py" 63 | "\n\ndef multiply(a, b):\n return a * b\n\n```" 64 | ), 65 | ], 66 | [ 67 | "{{expand('operations.py@divide')}}", 68 | ( 69 | "```python\n# Content of operations.py" 70 | "\n\ndef divide(a, b):\n return a / b\n\n```" 71 | ), 72 | ], 73 | [ 74 | "{{expand('operations.py', symbols='multiply')}}", 75 | ( 76 | "```python\n# Content of operations.py" 77 | "\n\n\ndef multiply(a, b):\n return a * b\n```" 78 | ), 79 | ], 80 | [ 81 | "{{expand('operations.py', symbols='divide')}}", 82 | ( 83 | "```python\n# Content of operations.py" 84 | "\n\n\ndef divide(a, b):\n return a / b\n```" 85 | ), 86 | ], 87 | [ 88 | "{{expand('operations.py', symbols=['multiply', 'divide'])}}", 89 | ( 90 | "```python\n# Content of operations.py" 91 | "\n\n\ndef multiply(a, b):\n return a * b\n\n\n" 92 | "def divide(a, b):\n return a / b\n```" 93 | ), 94 | ], 95 | [ 96 | "{{expand('env.yaml')}}", 97 | ("```yaml\n# Content of env.yaml\n\nkey: value\n\n```"), 98 | ], 99 | [ 100 | "{{expand('with_imports.py', symbols='uses_math')}}", 101 | ( 102 | "```python\n# Content of with_imports.py" 103 | "\n\nimport math\n\n\ndef uses_math(x):\n " 104 | "return math.something()\n```" 105 | ), 106 | ], 107 | [ 108 | "{{expand('with_imports.py', symbols='uses_both')}}", 109 | ( 110 | "```python\n# Content of with_imports.py\n\nimport math" 111 | "\nimport json\n\n\ndef uses_both(x):\n " 112 | "a = math.something()\n b = json.another()\n return a, b\n```" 113 | ), 114 | ], 115 | ], 116 | ids=[ 117 | "simple", 118 | "@", 119 | "@-another", 120 | "symbols-multiply", 121 | "symbols-divide", 122 | "symbols-many", 123 | "non-python", 124 | "retrieves-single-import", 125 | "retrieves-many-imports", 126 | ], 127 | ) 128 | def test_expand(tmp_empty, create_files, content, expected): 129 | assert expand(content) == expected 130 | 131 | 132 | def test_expand_with_args(tmp_empty, create_files): 133 | content = """ 134 | {{expand('sum.py')}} 135 | """ 136 | 137 | expected = ( 138 | "\n```python skip=True\n# Content of " 139 | "sum.py\n\ndef sum(a, b):\n return a + b\n\n```" 140 | ) 141 | assert expand(content, args="skip=True") == expected 142 | 143 | 144 | @pytest.mark.parametrize( 145 | "content, expected", 146 | [ 147 | [ 148 | "{{expand('operations.py', lines=(1, 2))}}", 149 | ("```python\n# Content of " "operations.py\n\ndef multiply(a, b):\n```"), 150 | ], 151 | [ 152 | "{{expand('operations.py@divide', lines=(1, 2))}}", 153 | ("```python\n# Content of " "operations.py\n\ndef divide(a, b):\n```"), 154 | ], 155 | ], 156 | ) 157 | def test_expand_with_lines(tmp_empty, create_files, content, expected): 158 | assert expand(content) == expected 159 | -------------------------------------------------------------------------------- /tests/test_find_block_lines.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jupyblog import util 4 | 5 | md_in = """ 6 | 7 | ```python 8 | ``` 9 | 10 | ```python id=hi 11 | 12 | 13 | 14 | ``` 15 | 16 | ```python 17 | 18 | ``` 19 | 20 | """ 21 | 22 | md_out = """ 23 | 24 | ```python 25 | ``` 26 | 27 | ``` 28 | {{ '0' }} 29 | ``` 30 | 31 | 32 | ```python id=hi 33 | 34 | 35 | 36 | ``` 37 | 38 | 39 | ``` 40 | {{ '1' }} 41 | ``` 42 | 43 | ```python 44 | 45 | ``` 46 | 47 | 48 | ``` 49 | {{ '2' }} 50 | ``` 51 | """ 52 | 53 | 54 | @pytest.mark.xfail 55 | def test_add_output_tags(): 56 | assert util.add_output_tags(md_in) == md_out 57 | -------------------------------------------------------------------------------- /tests/test_images.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from jupyblog import images 3 | 4 | one = """ 5 | # Header 6 | 7 | ![something](path.png) 8 | 9 | ```python 10 | # Not a header 11 | ``` 12 | """ 13 | 14 | one_expected = [ 15 | ("![something](path.png)", "path.png"), 16 | ] 17 | 18 | two = """ 19 | # Header 20 | 21 | ![something](path.png) 22 | 23 | # Another 24 | 25 | ![another](another/path.png) 26 | """ 27 | 28 | two_expected = [ 29 | ("![something](path.png)", "path.png"), 30 | ("![another](another/path.png)", "another/path.png"), 31 | ] 32 | 33 | special_characters = """ 34 | ![something.png](path.png) 35 | ![hello_world.png](another.png) 36 | ![some image](some-image.png) 37 | """ 38 | 39 | special_characters_expected = [ 40 | ("![something.png](path.png)", "path.png"), 41 | ("![hello_world.png](another.png)", "another.png"), 42 | ("![some image](some-image.png)", "some-image.png"), 43 | ] 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "post, links", 48 | [ 49 | [one, one_expected], 50 | [two, two_expected], 51 | [special_characters, special_characters_expected], 52 | ], 53 | ids=[ 54 | "one", 55 | "two", 56 | "special-chars", 57 | ], 58 | ) 59 | def test_find_images(post, links): 60 | assert list(images.find_images(post)) == list(links) 61 | 62 | 63 | @pytest.mark.parametrize("post", [one, two]) 64 | def test_get_first_image_path(post): 65 | assert images.get_first_image_path(post) == "path.png" 66 | 67 | 68 | def test_file_process_image_links(): 69 | post = "![img](static/img.png)\n\n![img2](static/img2.png)" 70 | post_new = images.process_image_links(post, "post", absolute=True) 71 | expected = "![img](/post/static/img.png)\n\n![img2](/post/static/img2.png)" 72 | assert post_new == expected 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "test_input,expected", 77 | [ 78 | ("![img](img.png)", "![img](/name/img.png)"), 79 | ("![some_image](some_image.png)", "![some_image](/name/some_image.png)"), 80 | ("![some-image](some-image.png)", "![some-image](/name/some-image.png)"), 81 | ("![some-ima_ge](some-ima_ge.png)", "![some-ima_ge](/name/some-ima_ge.png)"), 82 | ], 83 | ) 84 | def test_process_image_links(test_input, expected): 85 | post_new = images.process_image_links(test_input, "name", absolute=True) 86 | assert post_new == expected 87 | 88 | 89 | def test_process_image_links_relative(): 90 | test_input = "![img](img.png)" 91 | post_new = images.process_image_links(test_input, "name", absolute=False) 92 | assert post_new == "![img](name/img.png)" 93 | 94 | 95 | one_placeholders_expected = """ 96 | # Header 97 | 98 | **ADD path.png HERE** 99 | ![something](path.png) 100 | 101 | ```python 102 | # Not a header 103 | ``` 104 | """ 105 | 106 | two_placeholders_expected = """ 107 | # Header 108 | 109 | **ADD path.png HERE** 110 | ![something](path.png) 111 | 112 | # Another 113 | 114 | **ADD another/path.png HERE** 115 | ![another](another/path.png) 116 | """ 117 | 118 | 119 | @pytest.mark.parametrize( 120 | "post, expected", 121 | [ 122 | [one, one_placeholders_expected], 123 | [two, two_placeholders_expected], 124 | ], 125 | ids=["one", "two"], 126 | ) 127 | def test_replace_images_with_placeholders(post, expected): 128 | assert images.add_image_placeholders(post) == expected 129 | -------------------------------------------------------------------------------- /tests/test_md.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | from jupyblog import md 5 | import nbformat 6 | from ploomber_engine import execute_notebook 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "content, expected", 11 | [ 12 | ["# header\n\ncontent", "# header\n\ncontent"], 13 | [ 14 | """\ 15 | --- 16 | a: 1 17 | --- 18 | # Header""", 19 | """\ 20 | # Header""", 21 | ], 22 | ], 23 | ) 24 | def test_delete_front_matter(content, expected): 25 | assert md.delete_metadata(content) == expected 26 | 27 | 28 | def test_error_if_no_front_matter(): 29 | content = """ 30 | # Hello 31 | """ 32 | 33 | with pytest.raises(ValueError) as excinfo: 34 | md.replace_metadata(content, {"key": "value"}) 35 | 36 | assert str(excinfo.value) == "Markdown file does not have YAML front matter" 37 | 38 | 39 | one = """\ 40 | --- 41 | --- 42 | """ 43 | 44 | two = """\ 45 | --- 46 | a: 1 47 | --- 48 | """ 49 | 50 | three = """\ 51 | --- 52 | a: 1 53 | b: 54 | - 2 55 | --- 56 | """ 57 | 58 | 59 | @pytest.mark.parametrize( 60 | "md_str, metadata", 61 | [ 62 | [one, {}], 63 | [two, {"a": 1}], 64 | [three, {"a": 1, "b": [2]}], 65 | ], 66 | ) 67 | def test_parse_metadata(md_str, metadata): 68 | assert md.parse_metadata(md_str, validate=False) == metadata 69 | 70 | 71 | @pytest.mark.parametrize( 72 | "content, lines, expected", 73 | [ 74 | [ 75 | """ 76 | some line 77 | 78 | another line 79 | """, 80 | ("some line", "another line"), 81 | {"some line": 2, "another line": 4}, 82 | ], 83 | [ 84 | """ 85 | some line 86 | 87 | 88 | another line 89 | """, 90 | ("some line", "another line"), 91 | {"some line": 2, "another line": 5}, 92 | ], 93 | [ 94 | """ 95 | some line 96 | 97 | 98 | another line 99 | """, 100 | ("some line", "missing line"), 101 | { 102 | "some line": 2, 103 | }, 104 | ], 105 | ], 106 | ) 107 | def test_find_lines(content, lines, expected): 108 | assert md.find_lines(content, lines) == expected 109 | 110 | 111 | @pytest.mark.parametrize( 112 | "content, lines, expected", 113 | [ 114 | [ 115 | """ 116 | start 117 | 118 | something 119 | 120 | end 121 | 122 | hello 123 | """, 124 | (2, 6), 125 | """ 126 | 127 | hello""", 128 | ] 129 | ], 130 | ) 131 | def test_delete_between_line_no(content, lines, expected): 132 | assert md.delete_between_line_no(content, lines) == expected 133 | 134 | 135 | @pytest.mark.parametrize( 136 | "content, lines, expected", 137 | [ 138 | [ 139 | """ 140 | start 141 | 142 | something 143 | 144 | end 145 | 146 | hello 147 | """, 148 | ("start", "end"), 149 | """ 150 | 151 | hello""", 152 | ] 153 | ], 154 | ) 155 | def test_delete_between_line_content(content, lines, expected): 156 | assert md.delete_between_line_content(content, lines) == expected 157 | 158 | 159 | @pytest.mark.parametrize( 160 | "content, lines, expected", 161 | [ 162 | [ 163 | """ 164 | start 165 | 166 | something 167 | 168 | end 169 | 170 | hello 171 | """, 172 | ("start", "end"), 173 | """ 174 | something 175 | """, 176 | ] 177 | ], 178 | ) 179 | def test_extract_between_line_content(content, lines, expected): 180 | assert md.extract_between_line_content(content, lines) == expected 181 | 182 | 183 | def test_markdownast_iter_blocks(): 184 | doc = """ 185 | ```python 186 | 1 + 1 187 | ``` 188 | 189 | ```python 190 | 2 + 2 191 | ``` 192 | 193 | """ 194 | ast = md.MarkdownAST(doc) 195 | 196 | blocks = list(ast.iter_blocks()) 197 | 198 | assert blocks == [ 199 | {"type": "block_code", "text": "1 + 1\n", "info": "python"}, 200 | {"type": "block_code", "text": "2 + 2\n", "info": "python"}, 201 | ] 202 | 203 | 204 | def test_markdownast_replace_blocks(): 205 | doc = """\ 206 | ```python 207 | 1 + 1 208 | ``` 209 | 210 | ```python 211 | 2 + 2 212 | ```\ 213 | """ 214 | ast = md.MarkdownAST(doc) 215 | 216 | new = ast.replace_blocks(["hello", "bye"]) 217 | 218 | assert new == "hello\n\nbye" 219 | 220 | 221 | def test_gistuploader(): 222 | doc = """\ 223 | Some text 224 | 225 | ```python 226 | 1 + 1 227 | ``` 228 | 229 | More text 230 | 231 | ```python 232 | 2 + 2 233 | ```\ 234 | """ 235 | 236 | mock_api = Mock() 237 | mock_response = Mock() 238 | mock_response.id = "some_id" 239 | mock_api.gists.create.return_value = mock_response 240 | 241 | ast = md.GistUploader(doc) 242 | ast._api = mock_api 243 | 244 | out = ast.upload_blocks("prefix") 245 | 246 | expected = ( 247 | "Some text\n\nhttps://gist.github.com/some_id\n\n" 248 | "More text\n\nhttps://gist.github.com/some_id" 249 | ) 250 | 251 | assert out == expected 252 | 253 | 254 | def test_tomd(tmp_empty): 255 | nb = nbformat.v4.new_notebook() 256 | nb.cells = [nbformat.v4.new_code_cell(source=cell) for cell in ("1 + 1", "2 + 2")] 257 | 258 | execute_notebook(nb, "post.ipynb") 259 | 260 | nb = nbformat.read("post.ipynb", as_version=nbformat.NO_CONVERT) 261 | # add this cell to ensure that to_md doesn't run the notebook 262 | nb.cells[0].source = "1 / 0" 263 | nbformat.write(nb, "post.ipynb") 264 | 265 | out = md.to_md("post.ipynb") 266 | 267 | assert "ZeroDivisionError" not in out 268 | assert "Console output" in out 269 | assert "2" in out 270 | assert "4" in out 271 | -------------------------------------------------------------------------------- /tests/test_medium.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jupyblog import medium 4 | 5 | one = """ 6 | # Header 7 | 8 | ![img](img.png) 9 | """ 10 | 11 | one_expected = """ 12 | # Header 13 | 14 | ![img](img.png) 15 | """ 16 | 17 | two = """ 18 | # Header 19 | 20 | ![img](img.png) 21 | 22 | # Another 23 | 24 | ```python 25 | 1 + 1 26 | ``` 27 | """ 28 | 29 | two_expected = """ 30 | # Header 31 | 32 | ![img](img.png) 33 | 34 | # Another 35 | 36 | ```py 37 | 1 + 1 38 | ``` 39 | """ 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "md, expected", 44 | [ 45 | [one, one_expected], 46 | [two, two_expected], 47 | ], 48 | ids=["one", "two"], 49 | ) 50 | def test_apply_language_map(md, expected): 51 | assert medium.apply_language_map(md, {"python": "py"}) == expected 52 | 53 | 54 | @pytest.mark.parametrize( 55 | "md, expected", 56 | [ 57 | [one, [("Header", 1)]], 58 | [two, [("Header", 1), ("Another", 1)]], 59 | ], 60 | ids=["one", "two"], 61 | ) 62 | def test_find_headers(md, expected): 63 | assert list(medium.find_headers(md)) == expected 64 | 65 | 66 | one_out = """ 67 | ## Header 68 | 69 | ![img](img.png) 70 | """ 71 | 72 | two_out = """ 73 | ## Header 74 | 75 | ![img](img.png) 76 | 77 | ## Another 78 | 79 | ```python 80 | 1 + 1 81 | ``` 82 | """ 83 | 84 | all_headers = """ 85 | # H1 86 | ## H2 87 | ### H3 88 | #### H4 89 | ##### H5 90 | """ 91 | 92 | all_headers_expected = """ 93 | ## H1 94 | ### H2 95 | #### H3 96 | ##### H4 97 | ###### H5 98 | """ 99 | 100 | 101 | @pytest.mark.parametrize( 102 | "md, expected", 103 | [ 104 | [one, one_out], 105 | [two, two_out], 106 | [all_headers, all_headers_expected], 107 | ], 108 | ids=["one", "two", "headers"], 109 | ) 110 | def test_replace_headers(md, expected): 111 | assert medium.replace_headers(md) == expected 112 | 113 | 114 | def test_error_if_level_six_header(): 115 | with pytest.raises(ValueError) as excinfo: 116 | medium.replace_headers("###### H6") 117 | 118 | assert str(excinfo.value) == "Level 6 headers aren ot supoprted: 'H6'" 119 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from jupyblog.models import FrontMatter 2 | 3 | 4 | def test_defaults(): 5 | fm = FrontMatter() 6 | 7 | assert not fm.jupyblog.serialize_images 8 | assert not fm.jupyblog.allow_expand 9 | assert fm.jupyblog.execute_code 10 | -------------------------------------------------------------------------------- /tests/test_render.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pathlib import Path 3 | 4 | import jupytext 5 | import nbformat 6 | from ploomber_engine.ipython import PloomberClient 7 | from jupyblog.md import MarkdownRenderer 8 | from jupyblog.exceptions import InputPostException 9 | 10 | simple = """\ 11 | --- 12 | title: title 13 | description: description 14 | jupyblog: 15 | execute_code: true 16 | --- 17 | 18 | ```python 19 | print(1 + 1) 20 | print(1 + 2) 21 | 1 + 5 22 | ``` 23 | """ 24 | 25 | expected = """\ 26 | **Console output (1/2):** 27 | 28 | ```txt 29 | 2 30 | 3 31 | ``` 32 | 33 | **Console output (2/2):** 34 | 35 | ```txt 36 | 6 37 | ```\ 38 | """ 39 | 40 | skip = """\ 41 | --- 42 | title: title 43 | description: description 44 | jupyblog: 45 | execute_code: true 46 | --- 47 | 48 | ```python skip=True 49 | 1 + 1 50 | ``` 51 | 52 | ```python 53 | 21 + 21 54 | ``` 55 | """ 56 | 57 | skip_expected = """\ 58 | ```python 59 | 1 + 1 60 | ``` 61 | 62 | ```python 63 | 21 + 21 64 | ``` 65 | 66 | 67 | **Console output (1/1):** 68 | 69 | ```txt 70 | 42 71 | ```\ 72 | """ 73 | 74 | image = """\ 75 | --- 76 | title: title 77 | description: description 78 | jupyblog: 79 | execute_code: true 80 | --- 81 | 82 | ```python 83 | from IPython.display import Image 84 | Image('jupyter.png') 85 | ``` 86 | """ 87 | 88 | image_expected = """\ 89 | ```python 90 | from IPython.display import Image 91 | Image('jupyter.png') 92 | ``` 93 | 94 | 95 | **Console output (1/1):** 96 | 97 |