├── .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 | [](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., `` becomes ``)
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., ``) 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., `` becomes ``)
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., ``) 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 | x |
86 |
87 |
88 |
89 |
90 | 0 |
91 | 1 |
92 |
93 |
94 | 1 |
95 | 2 |
96 |
97 |
98 | 2 |
99 | 3 |
100 |
101 |
102 |
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 | " x | \n",
384 | "
\n",
385 | " \n",
386 | " \n",
387 | " \n",
388 | " 0 | \n",
389 | " 1 | \n",
390 | "
\n",
391 | " \n",
392 | " 1 | \n",
393 | " 2 | \n",
394 | "
\n",
395 | " \n",
396 | " 2 | \n",
397 | " 3 | \n",
398 | "
\n",
399 | " \n",
400 | "
\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 | 
--------------------------------------------------------------------------------
/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 | 
--------------------------------------------------------------------------------
/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"")
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 | 
--------------------------------------------------------------------------------
/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 | 
--------------------------------------------------------------------------------
/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 | 
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 "" 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 "" 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 "" 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 | 
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 "" 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 x | \n'
20 | "
\n \n \n \n 0 | \n "
21 | "0 | \n
\n \n 1 | \n 1 | \n "
22 | "
\n \n 2 | \n 2 | \n
\n "
23 | "\n
\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 | 
8 |
9 | ```python
10 | # Not a header
11 | ```
12 | """
13 |
14 | one_expected = [
15 | ("", "path.png"),
16 | ]
17 |
18 | two = """
19 | # Header
20 |
21 | 
22 |
23 | # Another
24 |
25 | 
26 | """
27 |
28 | two_expected = [
29 | ("", "path.png"),
30 | ("", "another/path.png"),
31 | ]
32 |
33 | special_characters = """
34 | 
35 | 
36 | 
37 | """
38 |
39 | special_characters_expected = [
40 | ("", "path.png"),
41 | ("", "another.png"),
42 | ("", "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 = "\n\n"
70 | post_new = images.process_image_links(post, "post", absolute=True)
71 | expected = "\n\n"
72 | assert post_new == expected
73 |
74 |
75 | @pytest.mark.parametrize(
76 | "test_input,expected",
77 | [
78 | ("", ""),
79 | ("", ""),
80 | ("", ""),
81 | ("", ""),
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 = ""
91 | post_new = images.process_image_links(test_input, "name", absolute=False)
92 | assert post_new == ""
93 |
94 |
95 | one_placeholders_expected = """
96 | # Header
97 |
98 | **ADD path.png HERE**
99 | 
100 |
101 | ```python
102 | # Not a header
103 | ```
104 | """
105 |
106 | two_placeholders_expected = """
107 | # Header
108 |
109 | **ADD path.png HERE**
110 | 
111 |
112 | # Another
113 |
114 | **ADD another/path.png HERE**
115 | 
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 | 
9 | """
10 |
11 | one_expected = """
12 | # Header
13 |
14 | 
15 | """
16 |
17 | two = """
18 | # Header
19 |
20 | 
21 |
22 | # Another
23 |
24 | ```python
25 | 1 + 1
26 | ```
27 | """
28 |
29 | two_expected = """
30 | # Header
31 |
32 | 
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 | 
70 | """
71 |
72 | two_out = """
73 | ## Header
74 |
75 | 
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 | 