diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..83e659c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: "Release" + +on: + workflow_dispatch: + inputs: + tag: + description: "The version to tag" + type: string + sha: + description: "The full sha of the commit to be release. If omitted, the most recent commit on the default branch will be used." + default: "" + type: string +jobs: + tag-release: + name: Tag Release + runs-on: ubuntu-latest + if: ${{ inputs.tag }} + needs: pypi-publish + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha }} + - name: Git tag + run: | + git config user.name "Github Actions" + git config user.email "actions@github.com" + git tag -m "${{ inputs.tag }}" "${{ inputs.tag }}" + git push --tags + + pypi-publish: + name: upload release to PyPI + runs-on: ubuntu-latest + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + + steps: + # retrieve your distributions here + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha }} + + - name: Install rye + uses: eifinger/setup-rye@v1 + with: + enable-cache: true + - name: Install Dependencies + run: rye sync + - name: Build Project + run: rye build + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip_existing: true + packages_dir: dist + verbose: true + + publish-release: + name: Publish to Github + runs-on: ubuntu-latest + needs: tag-release + steps: + - uses: softprops/action-gh-release@v1 + with: + draft: true + tag_name: ${{ inputs.tag }} + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bdc4cb7..2446a49 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,9 +8,32 @@ on: - opened - synchronize + jobs: + changes: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v3 + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + changed: + - 'tests/**' + - 'src/**' + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + lint: runs-on: ubuntu-latest + needs: changes + if: ${{needs.changes.outputs.changed == 'true'}} steps: - uses: actions/checkout@v3 - name: Install rye @@ -26,10 +49,13 @@ jobs: tests: runs-on: ubuntu-latest + needs: [changes, lint] + if: ${{needs.changes.outputs.changed == 'true'}} strategy: matrix: python-version: [3.8, 3.9, "3.10","3.11","3.12"] steps: + - uses: actions/checkout@v3 - name: Install rye uses: eifinger/setup-rye@v1 diff --git a/.gitignore b/.gitignore index e58fa36..9efe636 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ **/__pycache__/ tests/.coverage .coverage +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e37494 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 Tyler Baur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 69c4add..c5cd6f2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,77 @@ # PyMdown Extensions Blocks +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +![PyPI - Version](https://img.shields.io/pypi/v/pymdownx-blocks) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pymdownx-blocks) +![Tests](https://img.shields.io/github/actions/workflow/status/TillerBurr/pymdownx-blocks/tests) + + + These are a collection of blocks for the [PyMdown Extensions](https://facelessuser.github.io/pymdown-extensions) that I find useful. This project is not affiliated with the PyMdown Extensions project and is currently in a very early stage. Currently, there is only one block: `DirTree`. +## Installation + +```bash +pip install pymdownx-blocks +``` + +## Usage +This can be used in MkDocs or by itself. To use in a Python file, we use the following: + +```python +import markdown + +yaml_str=... +md=markdown.Markdown(extensions=['pymdownx_blocks.dirtree']) +``` + +To use in MkDocs, register the extension. + +```yaml +... +markdown_extensions: +... +- pymdownx_blocks.dirtree +... +``` + +In a markdown file, +``` +///dirtree + +root: +- subdir: + - File +- another subdir: + - anotherfile.txt + - file.csv +/// +``` + + +When rendered, this will produce the following tree + +
+

Directory Structure

+

+root +├── subdir +│ └── File +└── another subdir + ├── anotherfile.txt + └── file.csv +

+
+ + +## Contributing + +More blocks are always welcome! This project uses [rye](https:rye-up.com) for dependency +management. + +1. Fork the repository +2. Create a branch with the name of the block +3. Implement the block +4. Create a pull request. diff --git a/src/pymdownx_blocks/dirtree.py b/src/pymdownx_blocks/dirtree.py index f775cd7..ea601bb 100644 --- a/src/pymdownx_blocks/dirtree.py +++ b/src/pymdownx_blocks/dirtree.py @@ -10,11 +10,11 @@ class InvalidYAMLError(BaseException): - """Raised when YAML is invalid""" + """YAML Cannot be parsed.""" class InvalidTreeError(BaseException): - """Raised when the YAML is valid, but it not a valid tree""" + """YAML is valid, but not a valid directory tree.""" PIPE = "│" @@ -27,13 +27,35 @@ class InvalidTreeError(BaseException): def sorter(x: Union[TreeNode, str]) -> tuple[bool, str]: + """ + + Args: + x (Union[TreeNode, str]): An element of a TreeNode + + Returns: + (tuple[bool, str]): A tuple containing a bool that is `True` if x is a `str`, + and a str containing either `x` or the first (and only) key of `x` + + """ _type = isinstance(x, str) _value = x if isinstance(x, str) else next(iter(x.keys())) return _type, _value class DirTree: - def __init__(self, in_: str): + """ + Create a directory tree from a YAML string. + """ + + def __init__(self, in_: str) -> None: + """Initialize the tree object. + + Args: + in_ (str): YAML string + + Raises: + InvalidTreeError: Not a valid tree. + """ try: self.tree = yaml.safe_load(in_) if not isinstance(self.tree, dict): @@ -43,7 +65,7 @@ def __init__(self, in_: str): except yaml.error.YAMLError: raise InvalidYAMLError - def build_output( + def build( self, tree: TreeNode, current_index: int = 0, @@ -51,7 +73,22 @@ def build_output( parent_siblings: int = 0, item_sep: str = "", is_root: bool = True, - ): + ) -> str: + """ + Build the output. The final result is a string containing the tree. + + Args: + tree (TreeNode): A node in the tree. It is a dict containing values that are + a list of strings or dictionaries. + current_index (int): Current index of the item in the sequence. + prefix (str): String that is prepended to each line. + parent_siblings (int): Number of siblings the parent has. + item_sep (str): An Elbow or Tee. Used between lines, next to items. + is_root (bool): Declares the root of the tree. + + Returns: + A string containing the parsed tree. + """ _tree = "" directory = next(iter(tree.keys())) contents = tree.get(directory, []) @@ -79,7 +116,7 @@ def build_output( new_prefix = prefix + curr_prefix if isinstance(element, dict): # A dict is a subtree, build the subtree - _tree += self.build_output( + _tree += self.build( element, item_index, parent_siblings=num_siblings, @@ -89,19 +126,38 @@ def build_output( ) else: _tree += f"{new_prefix}{item_sep}{element}\n" + if is_root: + return _tree.rstrip() return _tree - def build(self) -> str: - final_tree = self.build_output(self.tree).rstrip() - return final_tree + def build_tree(self) -> str: + """Build the tree, without needing to pass in `self.tree`. + + Returns: + (str): The string containing the directory tree. + + """ + return self.build(self.tree) class DirTreeBlock(Block): + """Block Extension for the DirTree""" + NAME = "dirtree" ARGUMENT = None OPTIONS = {"type": ["", type_html_identifier]} def on_create(self, parent: etree.Element) -> etree.Element: + """ + + Args: + parent (xml.etree.ElementTree.Element): The parent element in the XML tree. + + Returns: + (xml.etree.ElementTree.Element): A div container with given classes and + title text. + + """ classes = ["admonition"] self_type = self.options["type"] if self_type: @@ -115,21 +171,41 @@ def on_create(self, parent: etree.Element) -> etree.Element: return el def on_end(self, block: etree.Element) -> None: + """Inserts the DirTree into the container + + Args: + block (xml.etree.ElementTree.Element): The block/container created in + `on_create` + """ yaml_content = block.find("p[2]") yaml_tree = block.findtext("p[2]") if yaml_tree and yaml_content is not None: block.remove(yaml_content) tree = DirTree(yaml_tree) dt = etree.SubElement(block, "pre") - dt.text = tree.build() + dt.text = tree.build_tree() class DirTreeExtension(BlocksExtension): + """Extension for the DirTree""" + def extendMarkdownBlocks( self, md: markdown.core.Markdown, block_mgr: BlocksProcessor ) -> None: + """Register the `DirTreeBlock` for pymdownx.blocks + + Args: + md (markdown.core.Markdown): The markdown parser + block_mgr (BlocksProcessor): The Generic Block Processor + """ block_mgr.register(DirTreeBlock, self.getConfigs()) def makeExtension(*args, **kwargs) -> DirTreeExtension: + """Register the extension with MkDocs. + + Returns: + (DirTreeExtension): The Directory Tree extension. + + """ return DirTreeExtension(*args, **kwargs) diff --git a/tests/test_dirtree.py b/tests/test_dirtree.py index 67e0e6d..d7d12bd 100644 --- a/tests/test_dirtree.py +++ b/tests/test_dirtree.py @@ -1,8 +1,8 @@ -from .utils import dedent, dedent_and_replace import pytest import yaml from pymdownx_blocks.dirtree import DirTree, InvalidTreeError, InvalidYAMLError +from tests.utils import dedent, dedent_and_replace root_dir_files = """ root_dir/: - file1 @@ -168,7 +168,7 @@ def test_invalid_yaml(): @pytest.mark.parametrize("class_type", ["note", "warning", "tip", "danger", None]) -@pytest.mark.parametrize("title", ["A Title", "Another Title", None]) +@pytest.mark.parametrize("title", ["A Title", "AnotherTitle",None]) def test_title(markdown_fixture, class_type, title): md = markdown_fixture( ["pymdownx_blocks.dirtree"], extension_config={"pymdownx_blocks.dirtree": []}