├── .github ├── ISSUE_TEMPLATE │ └── 1-bug.yaml └── workflows │ ├── mypy.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── _includes └── EXAMPLES.MD ├── examples ├── README.md ├── WCC-plt-Capture.png ├── chess_masters.py ├── cm1.txt ├── cm2.txt ├── cm3.txt ├── cm4.txt ├── cm5.txt ├── cm6.txt ├── cm62.txt ├── dependency_tree.py ├── directed-triads_test.py ├── graph_examples.py ├── graphlet_generator.py ├── networkx_integration.py ├── showcase.py ├── simple_graph.py └── triadic-census.py ├── mypy-phart.txt └── index.txt ├── pyproject.toml ├── requirements ├── default.txt ├── developer.txt ├── doc.txt ├── example.txt ├── extra.txt └── test.txt ├── setup.cfg ├── src └── phart │ ├── __init__.py │ ├── charset.py │ ├── cli.py │ ├── layout.py │ ├── renderer.py │ └── styles.py ├── tests ├── __init__.py ├── test_cli.py ├── test_performance.py ├── test_renderer.py └── test_styles.py └── tools └── generate_requirements.py /.github/ISSUE_TEMPLATE/1-bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve phart 3 | title: "[BUG]: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | 11 | Before proceeding, please confirm you're not looking for: 12 | - A Perl-based solution (we hear Graph::Easy is lovely this time of year) 13 | - A PHP+web server combo (because who doesn't love deploying a web server to render ASCII?) 14 | - Something that uses `requests.get("http://some-random-service.com/ascii")` 15 | 16 | - type: checkboxes 17 | attributes: 18 | label: Prerequisites 19 | options: 20 | - label: I am using pure Python (plus NetworkX, our one true dependency) 21 | required: true 22 | 23 | - type: textarea 24 | attributes: 25 | label: What happened? 26 | description: Also tell us what you expected to happen 27 | validations: 28 | required: true 29 | -------------------------------------------------------------------------------- /.github/workflows/mypy.yaml: -------------------------------------------------------------------------------- 1 | name: Type Checking 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | mypy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: "3.11" 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -e ".[test]" 24 | pip install mypy 25 | 26 | - name: Type check with mypy 27 | run: | 28 | mypy src 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.10", "3.11"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -e ".[test]" 28 | pip install ruff mypy 29 | 30 | - name: Run tests 31 | run: | 32 | python -m pytest 33 | 34 | - name: Ruff format check 35 | run: | 36 | ruff format --check . 37 | ruff check . 38 | 39 | - name: Type check with mypy 40 | run: | 41 | mypy src 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.code-workspace 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 111 | .pdm.toml 112 | .pdm-python 113 | .pdm-build/ 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv* 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com/hooks.html for more hooks 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | # Install pre-commit hooks via 12 | # pre-commit install 13 | 14 | - repo: https://github.com/pre-commit/mirrors-prettier 15 | rev: ffb6a759a979008c0e6dff86e39f4745a2d9eac4 # frozen: v3.1.0 16 | hooks: 17 | - id: prettier 18 | files: \.(html|md|toml|yml|yaml) 19 | args: [--prose-wrap=preserve] 20 | 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: 1dc9eb131c2ea4816c708e4d85820d2cc8542683 # frozen: v0.5.0 23 | hooks: 24 | - id: ruff 25 | args: ["--fix", "--show-fixes", "--exit-non-zero-on-fix"] 26 | - id: ruff-format 27 | 28 | - repo: local 29 | hooks: 30 | - id: generate_requirements.py 31 | name: generate_requirements.py 32 | language: system 33 | entry: python tools/generate_requirements.py 34 | files: "pyproject.toml|requirements/.*\\.txt|tools/generate_requirements.py" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Scott VanRavenswaay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phart 2 | 3 | **PHART:** The Python Hierarchical ASCII Representation Tool - A Pure Python graph visualization in ASCII, no external dependencies\* 4 | 5 | \* except NetworkX, which should be mentioned prominently. but **phart** will **not** require you to stand up a webserver to run PHP and install Perl and some libraries just to render a Graph in 7-bit text (or UTF-8 or Unicode) from Python. 6 | 7 | ## Features 8 | 9 | - Pure Python implementation 10 | - Render using 7-bit ASCII or unicode characters 11 | - No external dependencies (except NetworkX) 12 | - Multiple node styles (square, round, diamond, custom) 13 | - Customizable edge characters 14 | - Support for directed and undirected graphs 15 | - Handles cycles and complex layouts 16 | - Bidirectional edge support 17 | - Orthogonal (yet correct, though perhaps unexpected) Triad Layout 18 | 19 | ## Examples 20 | 21 |
22 | 23 | Example output PHARTs [click triangle/arrow to expand/collapse] 24 | 25 | ## PHART Graph Visualization Examples 26 | 27 | ================================= 28 | 29 | ### In preparation for a 1.0 PyPi release 30 | 31 | I was doing some last-minute testing and came across this, from the networkx gallery: 32 | 33 | https://networkx.org/documentation/latest/auto_examples/drawing/plot_chess_masters.html#sphx-glr-auto-examples-drawing-plot-chess-masters-py 34 | 35 | The code there creates a graph from some data pulled from a database of Chess masters tournaments and such at this site: 36 | 37 | https://chessproblem.my-free-games.com/chess/games/Download-PGN.php 38 | 39 | And plots it with matplotlib. It looked pretty complex so I thought as a lark I would see how difficult it would be to get phart to render the graph. The matplot can be seen here: 40 | 41 | ![screen capture of graph plot](https://github.com/scottvr/phart/blob/67bd3d02b6ad9cc4a8a09fe6fc2920a6712f5c7a/examples/WCC-plt-Capture.png) 42 | 43 | So, I added the following to the code at the networkx gallery page linked above: 44 | 45 | ``` 46 | from phart import ASCIIRenderer, NodeStyle 47 | 48 | .. existing code remains here ... 49 | 50 | ... then directly below the existing lines to create the nx graph: 51 | # make new undirected graph H without multi-edges 52 | H = nx.Graph(G) 53 | ... I added this: 54 | renderer=ASCIIRenderer(H) 55 | renderer.write_to_file("wcc.txt") 56 | ``` 57 | 58 | and ran the code. Immediately this was written to wcc.txt: 59 | 60 | ``` 61 | ---------------------------------[Botvinnik, Mikhail M]--------------------------------- 62 | | | | | | 63 | v | | v | | | v 64 | [Bronstein, David I]----[Euwe, Max]----[Keres, Paul]----[Petrosian, Tigran V]----[Reshevsky, Samuel H]----[Smyslov, Vassily V]----[Tal, Mikhail N] 65 | ^ | | 66 | | | v 67 | [Alekhine, Alexander A]----[Spassky, Boris V] 68 | | | | 69 | v | | | v 70 | [Bogoljubow, Efim D]----[Capablanca, Jose Raul] ---[Fischer, Robert J] 71 | |^ 72 | | 73 | [Lasker, Emanuel]-------------- 74 | | | 75 | v v v | v 76 | [Janowski, Dawid M]----[Marshall, Frank J]----[Schlechter, Carl]----[Steinitz, Wilhelm]----[Tarrasch, Siegbert] 77 | | | 78 | v v | | 79 | [Chigorin, Mikhail I]----[Gunsberg, Isidor A]----[Zukertort, Johannes H] 80 | 81 | 82 | [Karpov, Anatoly]----[Kasparov, Gary]----[Korchnoi, Viktor L] 83 | ``` 84 | 85 | No fuss. No muss. Just phart. 86 | 87 | ### Software Dependency Example: 88 | 89 | ``` 90 | [main.py] 91 | | 92 | v | v 93 | [config.py]----[utils.py] 94 | | | 95 | v | v 96 | [constants.py]----[helpers.py] 97 | ``` 98 | 99 | ### Organizational Hierarchy Example: 100 | 101 | ``` 102 | [CEO] 103 | | 104 | v v v 105 | [CFO]----[COO]----[CTO] 106 | | | | 107 | v v | v | | v v 108 | [Controller]----[Dev Lead]----[Marketing Dir]----[Research Lead]----[Sales Dir] 109 | ``` 110 | 111 | ### Network Topology Example: 112 | 113 | ``` 114 | [Router1] 115 | | 116 | v | v 117 | [Switch1]----[Switch2] 118 | | | 119 | v v | v 120 | [Server1]----[Server2] [Server3]----[Server4] 121 | ``` 122 | 123 | ### Workflow Example: 124 | 125 | ``` 126 | [Start] 127 | | 128 | v 129 | [Input] 130 | | 131 | |v 132 | [Validate] 133 | | 134 | v| 135 | --[Process] 136 | | ^ 137 | | v 138 | | [Check] 139 | | | 140 | | | v 141 | [Error]----[Success] 142 | | 143 | v | 144 | [Output]-- 145 | | 146 | v| 147 | [End] 148 | ``` 149 | 150 | ### DOT Import Example: 151 | 152 | ``` 153 | [A] 154 | | 155 | v | v 156 | [B]----[D] 157 | | | 158 | | v | 159 | --[C]--- 160 | | 161 | v 162 | [E] 163 | ``` 164 | 165 | ## Custom Styling Example: 166 | 167 | Different node styles for the same graph: 168 | 169 | ### Using MINIMAL style: 170 | 171 | ``` 172 | 0 173 | | 174 | v | v 175 | 1----2 176 | | | 177 | v v | v 178 | 3----4 5----6 179 | ``` 180 | 181 | ### Using SQUARE style: 182 | 183 | ``` 184 | [0] 185 | | 186 | v | v 187 | [1]----[2] 188 | | | 189 | v v | v 190 | [3]----[4] [5]----[6] 191 | ``` 192 | 193 | ### Using ROUND style: 194 | 195 | ``` 196 | (0) 197 | | 198 | v | v 199 | (1)----(2) 200 | | | 201 | v v | v 202 | (3)----(4) (5)----(6) 203 | ``` 204 | 205 | ### Using DIAMOND style: 206 | 207 | ``` 208 | <0> 209 | | 210 | v | v 211 | <1>----<2> 212 | | | 213 | v v | v 214 | <3>----<4> <5>----<6> 215 | ``` 216 | 217 |
218 | 219 | ## Why PHART? 220 | 221 | Because it demanded to come out. OK, sorry... Actually it had a few other names early on, but when it came time to upload to PyPi, we discovered the early names we chose were already taken so we had to choose a new name. We wanted to mash up the relevant terms ("graph", "ascii", "art", "chart", and such) and bonus if the new name is a fitting acronym. 222 | 223 | In the case of PHART, the acronym made from the first letters of the obvious first words to come to mind was discovered to spell PHART after the non abbreviated words were suggested. Fortuitous; so it had to be. Also, as I am beginning to update the usage instructions and the examples and their output in the README to more accurately reflect current capabilities, it occurs to me that the name may not be as fitting anymore. At first release PHART only handled DAGs and fairly strictly rendered a heirarchical layout. 224 | 225 | As it's capabilities have increased (by user request if you can believe that!) faster than my knowledge of Graphs and the layout thereof, and my lack of deep understanding of exactly what NetworkX's focus and strengths are (to be fair, their dev lists and Roadmap would seem to align quite well with me not fully understanding their focus and direction, and I mean no disrespect; it's way beyond me.) Anyway, as such at times I struggle with having to remind myself "that information is already known - to the graph - you don't need to calculate or keep track of that, similarly with certain layout decisions that really don't need to be pondered by PHART at all I have spent too many hours working on. 226 | 227 | So it might be more properly named something that indicates that it sometimes adheres mostly to a Hierarchical Layout strategy, but other times it might be more accurately branded an Orthogonal Layout. Either way, I'm not sure I knew either of those were terms for some known thing at the time I started implementing. Nevertheless, perhaps its name needs to expand along with its function. Something incorporating Orthogonal Layout. Hmm.. "**OL-PHART**" perhaps. Maybe it has become **B**oundary **I**nvariant **G**eodesic **O**rthogonal **L**ayout **PHART** - that is... 228 | 229 | **BIGOL-PHART** 230 | 231 | Regardless, you may pronounce it the obvious monosyllabic way, or as "eff art", or perhaps "pee heart", or any way that you like, so long as the audience you are speaking it to knows it is PHART you are referring to. 232 | 233 | ## Really, why? 234 | 235 | The mention of not being Perl or a PHP webapp may appear to be throwing shade at the existing solutions, but it is meant in a good-hearted way. Wrapping the OG Graph::Easy is a straightforeard way to go about it, and a web interface to the same is a project I might create as well, but Perl being installed is not the sure ubiquitous thing it once was, and spinning up a Docker container in order to add ascii art graph output to a python tool seemed a bit excessive. 236 | 237 | Additionally, I'm not sure how I didn't find pydot2ascii - which is native python - when I first looked for a solution, but even if I had seen it I may not have realized that I could have exported my NX DAG to DOT, and then used pydot2ascii to go from DOT to ascii art. 238 | 239 | So now we have PHART, and the ability to render a NX digraph in ASCII/Unicode, read a DOT file, read GraphML, and a few other things in a well-tested Python module published to PyPi. I hope you find it useful. 240 | 241 | ## Installation 242 | 243 | requires Python >= 3.10 and NetworkX >= 3.3 244 | 245 | ```bash 246 | pip install phart 247 | ``` 248 | 249 | ## The CLI 250 | 251 | These docs are pretty out of date now, which I will try to remedy soon. 252 | In the mean time, I should mention that the primary focus lately has been 253 | use from the CLI command `phart` which is installed when you install via pip. 254 | This repor is well ahead of the release in PyPi as I work on some specific 255 | graphlet features for a user. I will update the docs to match when the package is released. 256 | But to fill in some of the info garp here is the CLI usage info, which should be 257 | self-explanatory to many of you. 258 | 259 | ```bash 260 | └─$ phart --help 261 | usage: phart [-h] [--output OUTPUT] [--style {minimal,square,round,diamond,custom}] 262 | [--node-spacing NODE_SPACING] [--layer-spacing LAYER_SPACING] [--charset {ascii,unicode}] 263 | [--ascii] [--function FUNCTION] 264 | input 265 | 266 | PHART: Python Hierarchical ASCII Rendering Tool 267 | 268 | positional arguments: 269 | input Input file (.dot, .graphml, or .py format) 270 | 271 | options: 272 | -h, --help show this help message and exit 273 | --output OUTPUT, -o OUTPUT 274 | Output file (if not specified, prints to stdout) 275 | --style {minimal,square,round,diamond,custom} 276 | Node style (default: square) 277 | --node-spacing NODE_SPACING 278 | Horizontal space between nodes (default: 4) 279 | --layer-spacing LAYER_SPACING 280 | Vertical space between layers (default: 2) 281 | --charset {ascii,unicode} 282 | Character set to use for rendering (default: unicode) 283 | --ascii Force ASCII output (deprecated, use --charset ascii instead) 284 | --function FUNCTION, -f FUNCTION 285 | Function to call in Python file (default: main) 286 | ``` 287 | 288 | ## Quick Start 289 | 290 | ```python 291 | import networkx as nx 292 | from phart import ASCIIRenderer 293 | 294 | # Create a simple graph 295 | G = nx.DiGraph() 296 | G.add_edges_from([("A", "B"), ("A", "C"), ("B", "D")]) 297 | 298 | # Render it in ASCII 299 | renderer = ASCIIRenderer(G) 300 | print(renderer.render()) 301 | 302 | [A] 303 | │ 304 | v │ v 305 | [B]────[C] 306 | │ 307 | │ v 308 | ──[D] 309 | ``` 310 | 311 | The renderer shows edge direction using arrows: 312 | 313 | - v : downward flow 314 | - ^ : upward flow 315 | - > or < : horizontal flow 316 | 317 | These directional indicators are particularly useful for: 318 | 319 | - Dependency graphs 320 | - Workflow diagrams 321 | - Process flows 322 | - Any directed relationships 323 | 324 | # Extras 325 | 326 | ## Character Sets 327 | 328 | PHART supports multiple character sets for rendering: 329 | 330 | - `--charset unicode` (default): Uses Unicode box drawing characters and arrows for 331 | cleaner visualization 332 | - `--charset ascii`: Uses only 7-bit ASCII characters, ensuring maximum compatibility 333 | with all terminals 334 | 335 | Example: 336 | 337 | ```bash 338 | # Using Unicode (default) 339 | phart graph.dot 340 | # ┌─A─┐ 341 | # │ │ 342 | # └─B─┘ 343 | 344 | # Using ASCII only 345 | phart --charset ascii graph.dot 346 | # +-A-+ 347 | # | | 348 | # +-B-+ 349 | ``` 350 | 351 | ## File Format Support 352 | 353 | ### DOT Files 354 | 355 | - DOT file support 356 | - requires pydot 357 | 358 | ```bash 359 | pip install phart[extras] 360 | ``` 361 | 362 | or using requirements file 363 | 364 | ```bash 365 | pip install -r requirements\extra.txt 366 | ``` 367 | 368 | ### Example 369 | 370 | >>> dot = ''' 371 | ... digraph { 372 | ... A -> B 373 | ... B -> C 374 | ... } 375 | ... ''' 376 | >>> renderer = ASCIIRenderer.from_dot(dot) 377 | >>> print(renderer.render()) 378 | A 379 | | 380 | B 381 | | 382 | C 383 | >>> 384 | 385 | ### Note on DOT format support: 386 | 387 | --- 388 | 389 | PHART uses pydot for DOT format support. When processing DOT strings containing 390 | multiple graph definitions, only the first graph will be rendered. For more 391 | complex DOT processing needs, you can convert your graphs using NetworkX's 392 | various graph reading utilities before passing them to PHART. 393 | 394 | ### GraphML Files 395 | 396 | PHART supports reading GraphML files: 397 | 398 | ```python 399 | renderer = ASCIIRenderer.from_graphml("graph.graphml") 400 | print(renderer.render()) 401 | ``` 402 | 403 | or, of course just 404 | ```bash 405 | phart graph.graphml 406 | ``` 407 | ## Python Files 408 | 409 | PHART can directly execute Python files that create and render graphs. When given a Python file, PHART will: 410 | 411 | 1. First try to execute the specified function (if `--function` is provided) 412 | 2. Otherwise, try to execute a `main()` function if one exists 413 | 3. Finally, execute code in the `if __name__ == "__main__":` block 414 | 415 | Example Python file: 416 | 417 | ```python 418 | import networkx as nx 419 | from phart import ASCIIRenderer 420 | 421 | def demonstrate_graph(): 422 | # Create a simple directed graph 423 | G = nx.DiGraph() 424 | G.add_edges_from([("A", "B"), ("B", "C")]) 425 | 426 | # Create renderer and display the graph 427 | renderer = ASCIIRenderer(G) 428 | print(renderer.render()) 429 | 430 | if __name__ == "__main__": 431 | demonstrate_graph() 432 | ``` 433 | 434 | You can execute this file in several ways: 435 | 436 | ```bash 437 | # Execute main() or __main__ block (default behavior) 438 | phart graph.py 439 | 440 | # Execute a specific function 441 | phart graph.py --function demonstrate_graph 442 | 443 | # Use specific rendering options 444 | phart graph.py --charset ascii --style round 445 | ``` 446 | 447 | ### Option Handling 448 | 449 | When executing Python files, PHART intelligently merges command-line options with any 450 | options specified in your code: 451 | 452 | - Options set in your Python code (like custom_decorators or specific node styles) are preserved 453 | - Command-line options will override general settings (like --charset or --style) 454 | - Custom settings (like custom_decorators) are never overridden by command-line defaults 455 | 456 | This means you can set specific options in your code while still using command-line 457 | options to adjust general rendering settings. 458 | 459 | ## License 460 | 461 | MIT License 462 | -------------------------------------------------------------------------------- /_includes/EXAMPLES.MD: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 | ## PHART Graph Visualization Examples 6 | 7 | ================================= 8 | 9 | ### In preparation for a 1.0 PyPi release 10 | 11 | I was doing some last-minute testing and came across this, from the networkx gallery: 12 | 13 | https://networkx.org/documentation/latest/auto_examples/drawing/plot_chess_masters.html#sphx-glr-auto-examples-drawing-plot-chess-masters-py 14 | 15 | The code there creates a graph from some data pulled from a database of Chess masters tournaments and such at this site: 16 | 17 | https://chessproblem.my-free-games.com/chess/games/Download-PGN.php 18 | 19 | And plots it with matplotlib. It looked pretty complex so I thought as a lark I would see how difficult it would be to get phart to render the graph. The matplot can be seen here: 20 | 21 | ![screen capture of graph plot](https://github.com/scottvr/phart/blob/67bd3d02b6ad9cc4a8a09fe6fc2920a6712f5c7a/examples/WCC-plt-Capture.png) 22 | 23 | So, I added the following to the code at the networkx gallery page linked above: 24 | 25 | ``` 26 | from phart import ASCIIRenderer, NodeStyle 27 | 28 | .. existing code remains here ... 29 | 30 | ... then directly below the existing lines to create the nx graph: 31 | # make new undirected graph H without multi-edges 32 | H = nx.Graph(G) 33 | ... I added this: 34 | renderer=ASCIIRenderer(H) 35 | renderer.write_to_file("wcc.txt") 36 | ``` 37 | 38 | and ran the code. Immediately this was written to wcc.txt: 39 | 40 | ``` 41 | ---------------------------------[Botvinnik, Mikhail M]--------------------------------- 42 | | | | | | 43 | v | | v | | | v 44 | [Bronstein, David I]----[Euwe, Max]----[Keres, Paul]----[Petrosian, Tigran V]----[Reshevsky, Samuel H]----[Smyslov, Vassily V]----[Tal, Mikhail N] 45 | ^ | | 46 | | | v 47 | [Alekhine, Alexander A]----[Spassky, Boris V] 48 | | | | 49 | v | | | v 50 | [Bogoljubow, Efim D]----[Capablanca, Jose Raul] ---[Fischer, Robert J] 51 | |^ 52 | | 53 | [Lasker, Emanuel]-------------- 54 | | | 55 | v v v | v 56 | [Janowski, Dawid M]----[Marshall, Frank J]----[Schlechter, Carl]----[Steinitz, Wilhelm]----[Tarrasch, Siegbert] 57 | | | 58 | v v | | 59 | [Chigorin, Mikhail I]----[Gunsberg, Isidor A]----[Zukertort, Johannes H] 60 | 61 | 62 | [Karpov, Anatoly]----[Kasparov, Gary]----[Korchnoi, Viktor L] 63 | ``` 64 | 65 | No fuss. No muss. Just phart. 66 | 67 | ### Software Dependency Example: 68 | 69 | ``` 70 | [main.py] 71 | | 72 | v | v 73 | [config.py]----[utils.py] 74 | | | 75 | v | v 76 | [constants.py]----[helpers.py] 77 | ``` 78 | 79 | ### Organizational Hierarchy Example: 80 | 81 | ``` 82 | [CEO] 83 | | 84 | v v v 85 | [CFO]----[COO]----[CTO] 86 | | | | 87 | v v | v | | v v 88 | [Controller]----[Dev Lead]----[Marketing Dir]----[Research Lead]----[Sales Dir] 89 | ``` 90 | 91 | ### Network Topology Example: 92 | 93 | ``` 94 | [Router1] 95 | | 96 | v | v 97 | [Switch1]----[Switch2] 98 | | | 99 | v v | v 100 | [Server1]----[Server2] [Server3]----[Server4] 101 | ``` 102 | 103 | ### Workflow Example: 104 | 105 | ``` 106 | [Start] 107 | | 108 | v 109 | [Input] 110 | | 111 | |v 112 | [Validate] 113 | | 114 | v| 115 | --[Process] 116 | | ^ 117 | | v 118 | | [Check] 119 | | | 120 | | | v 121 | [Error]----[Success] 122 | | 123 | v | 124 | [Output]-- 125 | | 126 | v| 127 | [End] 128 | ``` 129 | 130 | ### DOT Import Example: 131 | 132 | ``` 133 | [A] 134 | | 135 | v | v 136 | [B]----[D] 137 | | | 138 | | v | 139 | --[C]--- 140 | | 141 | v 142 | [E] 143 | ``` 144 | 145 | ## Custom Styling Example: 146 | 147 | Different node styles for the same graph: 148 | 149 | ### Using MINIMAL style: 150 | 151 | ``` 152 | 0 153 | | 154 | v | v 155 | 1----2 156 | | | 157 | v v | v 158 | 3----4 5----6 159 | ``` 160 | 161 | ### Using SQUARE style: 162 | 163 | ``` 164 | [0] 165 | | 166 | v | v 167 | [1]----[2] 168 | | | 169 | v v | v 170 | [3]----[4] [5]----[6] 171 | ``` 172 | 173 | ### Using ROUND style: 174 | 175 | ``` 176 | (0) 177 | | 178 | v | v 179 | (1)----(2) 180 | | | 181 | v v | v 182 | (3)----(4) (5)----(6) 183 | ``` 184 | 185 | ### Using DIAMOND style: 186 | 187 | ``` 188 | <0> 189 | | 190 | v | v 191 | <1>----<2> 192 | | | 193 | v v | v 194 | <3>----<4> <5>----<6> 195 | ``` 196 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # PHART Examples 2 | 3 | This directory contains example scripts demonstrating PHART's capabilities. 4 | 5 | ## Chess Masters Example (`chess_masters.py`) 6 | 7 | Demonstrates PHART's ability to handle complex real-world networks by visualizing World Chess Championship games from 1886-1985. This example shows how PHART can elegantly handle large, complex graphs with minimal configuration. 8 | 9 | To run this example: 10 | 11 | 1. Download WCC.pgn.bz2 from https://chessproblem.my-free-games.com/chess/games/Download-PGN.php 12 | 2. Place it in this directory 13 | 3. Run `python chess_masters.py` 14 | 15 | ## Simple Graph Examples (`simple_graph.py`) 16 | 17 | Basic examples showing PHART's core functionality: 18 | 19 | - Simple directed graphs 20 | - Different node styles (square, round, diamond, minimal) 21 | - Cycle handling 22 | 23 | Perfect for getting started with PHART. 24 | 25 | ## Dependency Tree Example (`dependency_tree.py`) 26 | 27 | Shows how to use PHART for visualizing package dependencies: 28 | 29 | - Typical package dependency trees 30 | - Circular dependency detection 31 | - Different layout approaches for dependency graphs 32 | 33 | ## NetworkX Integration (`networkx_integration.py`) 34 | 35 | Demonstrates PHART's seamless integration with NetworkX's various graph generators and algorithms. 36 | 37 | ## Graph Examples (`showcase.py`) 38 | 39 | A collection of different graph types and visualization scenarios: 40 | 41 | - Organizational hierarchies 42 | - Network topologies 43 | - Workflow diagrams 44 | - Process flows 45 | 46 | ## Example Screenshots 47 | 48 | The `WCC-plt-Capture.png` shows the matplotlib visualization of the chess masters graph for comparison with PHART's ASCII output. 49 | 50 | ## Running the Examples 51 | 52 | All examples can be run directly: 53 | 54 | ```bash 55 | python simple_graph.py 56 | python dependency_tree.py 57 | # etc. 58 | ``` 59 | 60 | No additional dependencies are required beyond PHART's core requirements (NetworkX), except for the chess example which needs the WCC.pgn.bz2 data file. 61 | -------------------------------------------------------------------------------- /examples/WCC-plt-Capture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottvr/phart/024960c93a2b3eb968f9d8ffe7966263defed6ae/examples/WCC-plt-Capture.png -------------------------------------------------------------------------------- /examples/chess_masters.py: -------------------------------------------------------------------------------- 1 | """ 2 | World Chess Championship visualization example. 3 | 4 | This example demonstrates PHART's ability to handle complex real-world graphs 5 | by visualizing World Chess Championship games from 1886-1985. 6 | 7 | Data source: https://chessproblem.my-free-games.com/chess/games/Download-PGN.php 8 | Original example adapted from NetworkX gallery: 9 | https://networkx.org/documentation/latest/auto_examples/drawing/plot_chess_masters.html 10 | """ 11 | # src path: examples\chess_masters.py 12 | 13 | import bz2 14 | from pathlib import Path 15 | import networkx as nx 16 | from phart import ASCIIRenderer, NodeStyle 17 | 18 | # Tag names specifying game info to store in edge data 19 | GAME_DETAILS = ["Event", "Date", "Result", "ECO", "Site"] 20 | 21 | 22 | def load_chess_games(pgn_file="WCC.pgn.bz2") -> nx.MultiDiGraph: 23 | """ 24 | Read chess games in PGN format. 25 | 26 | Parameters 27 | ---------- 28 | pgn_file : str 29 | Path to PGN file (can be bz2 compressed) 30 | 31 | Returns 32 | ------- 33 | NetworkX MultiDiGraph 34 | Graph where nodes are players and edges represent games 35 | """ 36 | G = nx.MultiDiGraph() 37 | game = {} 38 | 39 | with bz2.BZ2File(pgn_file) as datafile: 40 | lines = [line.decode().rstrip("\r\n") for line in datafile] 41 | 42 | for line in lines: 43 | if line.startswith("["): 44 | tag, value = line[1:-1].split(" ", 1) 45 | game[str(tag)] = value.strip('"') 46 | else: 47 | # Empty line after tag set indicates end of game info 48 | if game: 49 | white = game.pop("White") 50 | black = game.pop("Black") 51 | G.add_edge(white, black, **game) 52 | game = {} 53 | return G 54 | 55 | 56 | def main(): 57 | # Check if data file exists 58 | data_file = Path(__file__).parent / "WCC.pgn.bz2" 59 | if not data_file.exists(): 60 | print(f"Please download WCC.pgn.bz2 to {data_file}") 61 | print( 62 | "from: https://chessproblem.my-free-games.com/chess/games/Download-PGN.php" 63 | ) 64 | return 65 | 66 | # Load and analyze the data 67 | G = load_chess_games(data_file) 68 | print( 69 | f"Loaded {G.number_of_edges()} chess games between {G.number_of_nodes()} players\n" 70 | ) 71 | 72 | # Convert to undirected graph for visualization 73 | H = nx.Graph(G) 74 | 75 | # Create ASCII visualization 76 | renderer = ASCIIRenderer( 77 | H, 78 | node_style=NodeStyle.SQUARE, # Square brackets for player names 79 | node_spacing=4, # Space between nodes 80 | layer_spacing=1, # Compact vertical spacing 81 | ) 82 | 83 | # Save to file and display 84 | renderer.write_to_file("chess_masters.txt") 85 | print(renderer.render()) 86 | 87 | # Print some interesting statistics 88 | print("\nMost frequent openings:") 89 | openings = {} 90 | for _, _, game_info in G.edges(data=True): 91 | if "ECO" in game_info: 92 | eco = game_info["ECO"] 93 | openings[eco] = openings.get(eco, 0) + 1 94 | 95 | for eco, count in sorted(openings.items(), key=lambda x: x[1], reverse=True)[:5]: 96 | print(f"ECO {eco}: {count} games") 97 | 98 | 99 | if __name__ == "__main__": 100 | main() 101 | -------------------------------------------------------------------------------- /examples/cm1.txt: -------------------------------------------------------------------------------- 1 | Loaded 685 chess games between 25 players 2 | 3 | ↓────────────────────────────────────────────────────[Botvinnik, Mikhail M]─────────────────────────────────────────────────────↓ 4 | [Bronstein, David I] [Euwe, Max]────[Keres, Paul]────[Petrosian, Tigran V]────[Reshevsky, Samuel H]────[Smyslov, Vassily V] [Tal, Mikhail N] 5 | ↓──[Alekhine, Alexander A]↓ [Spassky, Boris V]───↓ 6 | [Bogoljubow, Efim D] [Capablanca, Jose Raul] [Fischer, Robert J] 7 | ↓─────────────────────────────────────[Lasker, Emanuel]────────────────────────────────────↓ 8 | [Janowski, Dawid M] [Marshall, Frank J]────[Schlechter, Carl]────[Steinitz, Wilhelm] [Tarrasch, Siegbert] 9 | [Chigorin, Mikhail I] [Gunsberg, Isidor A] [Zukertort, Johannes H] 10 | 11 | 12 | 13 | Most frequent openings: 14 | ECO C52: 20 games 15 | ECO D58: 14 games 16 | ECO D40: 11 games 17 | ECO D53: 11 games 18 | ECO C15: 11 games 19 | -------------------------------------------------------------------------------- /examples/cm2.txt: -------------------------------------------------------------------------------- 1 | Loaded 685 chess games between 25 players 2 | 3 | +----------------------------------------------------[Botvinnik, Mikhail M]-----------------------------------------------------+ 4 | v v v v v v v 5 | [Bronstein, David I] [Euwe, Max]----[Keres, Paul]----[Petrosian, Tigran V]----[Reshevsky, Samuel H]----[Smyslov, Vassily V] [Tal, Mikhail N] 6 | v v 7 | +--[Alekhine, Alexander A]+ [Spassky, Boris V]---+ 8 | v v v 9 | [Bogoljubow, Efim D] [Capablanca, Jose Raul] [Fischer, Robert J] 10 | v 11 | +-------------------------------------[Lasker, Emanuel]------------------------------------+ 12 | v v v v v 13 | [Janowski, Dawid M] [Marshall, Frank J]----[Schlechter, Carl]----[Steinitz, Wilhelm] [Tarrasch, Siegbert] 14 | v v v 15 | [Chigorin, Mikhail I] [Gunsberg, Isidor A] [Zukertort, Johannes H] 16 | 17 | 18 | 19 | Most frequent openings: 20 | ECO C52: 20 games 21 | ECO D58: 14 games 22 | ECO D40: 11 games 23 | ECO D53: 11 games 24 | ECO C15: 11 games 25 | -------------------------------------------------------------------------------- /examples/cm3.txt: -------------------------------------------------------------------------------- 1 | Loaded 685 chess games between 25 players 2 | 3 | +----------------------------------------------------[Botvinnik, Mikhail M]-----------------------------------------------------+ 4 | v v v v v v v 5 | [Bronstein, David I] [Euwe, Max]----[Keres, Paul]----[Petrosian, Tigran V]----[Reshevsky, Samuel H]----[Smyslov, Vassily V] [Tal, Mikhail N] 6 | v v 7 | +--[Alekhine, Alexander A]+ [Spassky, Boris V]---+ 8 | v v v 9 | [Bogoljubow, Efim D] [Capablanca, Jose Raul] [Fischer, Robert J] 10 | v 11 | +-------------------------------------[Lasker, Emanuel]------------------------------------+ 12 | v v v v v 13 | [Janowski, Dawid M] [Marshall, Frank J]----[Schlechter, Carl]----[Steinitz, Wilhelm] [Tarrasch, Siegbert] 14 | v v v 15 | [Chigorin, Mikhail I] [Gunsberg, Isidor A] [Zukertort, Johannes H] 16 | 17 | 18 | 19 | Most frequent openings: 20 | ECO C52: 20 games 21 | ECO D58: 14 games 22 | ECO D40: 11 games 23 | ECO D53: 11 games 24 | ECO C15: 11 games 25 | -------------------------------------------------------------------------------- /examples/cm4.txt: -------------------------------------------------------------------------------- 1 | Loaded 685 chess games between 25 players 2 | 3 | +────────────────────────────────────────────────────[Botvinnik, Mikhail M]─────────────────────────────────────────────────────+ 4 | ↓ ↓ ↓ ↓ ↓ ↓ ↓ 5 | [Bronstein, David I] [Euwe, Max]────[Keres, Paul]────[Petrosian, Tigran V]────[Reshevsky, Samuel H]────[Smyslov, Vassily V] [Tal, Mikhail N] 6 | ↓ ↓ 7 | +──[Alekhine, Alexander A]+ [Spassky, Boris V]───+ 8 | ↓ ↓ ↓ 9 | [Bogoljubow, Efim D] [Capablanca, Jose Raul] [Fischer, Robert J] 10 | ↓ 11 | +─────────────────────────────────────[Lasker, Emanuel]────────────────────────────────────+ 12 | ↓ ↓ ↓ ↓ ↓ 13 | [Janowski, Dawid M] [Marshall, Frank J]────[Schlechter, Carl]────[Steinitz, Wilhelm] [Tarrasch, Siegbert] 14 | ↓ ↓ ↓ 15 | [Chigorin, Mikhail I] [Gunsberg, Isidor A] [Zukertort, Johannes H] 16 | 17 | 18 | 19 | Most frequent openings: 20 | ECO C52: 20 games 21 | ECO D58: 14 games 22 | ECO D40: 11 games 23 | ECO D53: 11 games 24 | ECO C15: 11 games 25 | -------------------------------------------------------------------------------- /examples/cm5.txt: -------------------------------------------------------------------------------- 1 | Loaded 685 chess games between 25 players 2 | 3 | +----------------------------------------------------[Botvinnik, Mikhail M]-----------------------------------------------------+ 4 | v v v v v v v 5 | [Bronstein, David I] [Euwe, Max]----[Keres, Paul]----[Petrosian, Tigran V]----[Reshevsky, Samuel H]----[Smyslov, Vassily V] [Tal, Mikhail N] 6 | v v 7 | +--[Alekhine, Alexander A]+ [Spassky, Boris V]---+ 8 | v v v 9 | [Bogoljubow, Efim D] [Capablanca, Jose Raul] [Fischer, Robert J] 10 | v 11 | +-------------------------------------[Lasker, Emanuel]------------------------------------+ 12 | v v v v v 13 | [Janowski, Dawid M] [Marshall, Frank J]----[Schlechter, Carl]----[Steinitz, Wilhelm] [Tarrasch, Siegbert] 14 | v v v 15 | [Chigorin, Mikhail I] [Gunsberg, Isidor A] [Zukertort, Johannes H] 16 | 17 | 18 | 19 | Most frequent openings: 20 | ECO C52: 20 games 21 | ECO D58: 14 games 22 | ECO D40: 11 games 23 | ECO D53: 11 games 24 | ECO C15: 11 games 25 | -------------------------------------------------------------------------------- /examples/cm6.txt: -------------------------------------------------------------------------------- 1 | Loaded 685 chess games between 25 players 2 | 3 | +────────────────────────────────────────────────────[Botvinnik, Mikhail M]─────────────────────────────────────────────────────+ 4 | ↓ ↓ ↓ ↓ ↓ ↓ ↓ 5 | [Bronstein, David I] [Euwe, Max]────[Keres, Paul]────[Petrosian, Tigran V]────[Reshevsky, Samuel H]────[Smyslov, Vassily V] [Tal, Mikhail N] 6 | ↓ ↓ 7 | +──[Alekhine, Alexander A]+ [Spassky, Boris V]───+ 8 | ↓ ↓ ↓ 9 | [Bogoljubow, Efim D] [Capablanca, Jose Raul] [Fischer, Robert J] 10 | ↓ 11 | +─────────────────────────────────────[Lasker, Emanuel]────────────────────────────────────+ 12 | ↓ ↓ ↓ ↓ ↓ 13 | [Janowski, Dawid M] [Marshall, Frank J]────[Schlechter, Carl]────[Steinitz, Wilhelm] [Tarrasch, Siegbert] 14 | ↓ ↓ ↓ 15 | [Chigorin, Mikhail I] [Gunsberg, Isidor A] [Zukertort, Johannes H] 16 | 17 | 18 | 19 | Most frequent openings: 20 | ECO C52: 20 games 21 | ECO D58: 14 games 22 | ECO D40: 11 games 23 | ECO D53: 11 games 24 | ECO C15: 11 games 25 | -------------------------------------------------------------------------------- /examples/cm62.txt: -------------------------------------------------------------------------------- 1 | Loaded 685 chess games between 25 players 2 | 3 | +────────────────────────────────────────────────────[Botvinnik, Mikhail M]─────────────────────────────────────────────────────+ 4 | ↓ ↓ ↓ ↓ ↓ ↓ ↓ 5 | [Bronstein, David I] [Euwe, Max]────[Keres, Paul]────[Petrosian, Tigran V]────[Reshevsky, Samuel H]────[Smyslov, Vassily V] [Tal, Mikhail N] 6 | ↓ ↓ 7 | +──[Alekhine, Alexander A]+ [Spassky, Boris V]───+ 8 | ↓ ↓ ↓ 9 | [Bogoljubow, Efim D] [Capablanca, Jose Raul] [Fischer, Robert J] 10 | ↓ 11 | +─────────────────────────────────────[Lasker, Emanuel]────────────────────────────────────+ 12 | ↓ ↓ ↓ ↓ ↓ 13 | [Janowski, Dawid M] [Marshall, Frank J]────[Schlechter, Carl]────[Steinitz, Wilhelm] [Tarrasch, Siegbert] 14 | ↓ ↓ ↓ 15 | [Chigorin, Mikhail I] [Gunsberg, Isidor A] [Zukertort, Johannes H] 16 | 17 | 18 | 19 | Most frequent openings: 20 | ECO C52: 20 games 21 | ECO D58: 14 games 22 | ECO D40: 11 games 23 | ECO D53: 11 games 24 | ECO C15: 11 games 25 | -------------------------------------------------------------------------------- /examples/dependency_tree.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of using PHART to visualize package dependencies. 3 | """ 4 | # src path: examples\dependency_tree.py 5 | 6 | import networkx as nx 7 | from phart import ASCIIRenderer, NodeStyle 8 | 9 | 10 | def create_sample_dependencies(): 11 | """Create a sample package dependency graph.""" 12 | G = nx.DiGraph() 13 | 14 | # Main package dependencies 15 | dependencies = { 16 | "my-app": ["flask", "sqlalchemy", "celery"], 17 | "flask": ["werkzeug", "jinja2", "click"], 18 | "sqlalchemy": ["greenlet"], 19 | "celery": ["click", "redis"], 20 | "jinja2": ["markupsafe"], 21 | } 22 | 23 | # Add all edges 24 | for package, deps in dependencies.items(): 25 | for dep in deps: 26 | G.add_edge(package, dep) 27 | 28 | return G 29 | 30 | 31 | def create_circular_deps(): 32 | """Create a dependency graph with circular references.""" 33 | G = nx.DiGraph() 34 | 35 | # Circular dependency example 36 | dependencies = { 37 | "package_a": ["package_b", "requests"], 38 | "package_b": ["package_c"], 39 | "package_c": ["package_a"], # Creates cycle 40 | "requests": ["urllib3", "certifi"], 41 | } 42 | 43 | for package, deps in dependencies.items(): 44 | for dep in deps: 45 | G.add_edge(package, dep) 46 | 47 | return G 48 | 49 | 50 | def main(): 51 | print("Package Dependency Examples") 52 | print("=========================") 53 | 54 | # Simple dependency tree 55 | print("\nTypical Package Dependencies:") 56 | G = create_sample_dependencies() 57 | renderer = ASCIIRenderer(G, node_style=NodeStyle.MINIMAL) 58 | print(renderer.render()) 59 | 60 | # Circular dependencies 61 | print("\nCircular Dependencies:") 62 | G = create_circular_deps() 63 | renderer = ASCIIRenderer(G) 64 | print(renderer.render()) 65 | 66 | # Detect and print cycles 67 | cycles = list(nx.simple_cycles(G)) 68 | if cycles: 69 | print("\nDetected dependency cycles:") 70 | for cycle in cycles: 71 | print(" -> ".join(cycle + [cycle[0]])) 72 | 73 | 74 | if __name__ == "__main__": 75 | main() 76 | -------------------------------------------------------------------------------- /examples/directed-triads_test.py: -------------------------------------------------------------------------------- 1 | from phart import ASCIIRenderer, NodeStyle 2 | import networkx as nx 3 | 4 | 5 | def generate_triads(): 6 | triads = { 7 | "120C": [(1, 2), (2, 1), (1, 3), (3, 2)], # Four edges cycle 8 | "210": [(1, 2), (2, 1), (1, 3), (3, 2), (2, 3)], # Five edges 9 | "300": [(1, 2), (2, 1), (2, 3), (3, 2), (1, 3), (3, 1)], # Complete 10 | } 11 | 12 | graphs = {} 13 | for name, edge_list in triads.items(): 14 | G = nx.DiGraph() 15 | G.add_nodes_from([1, 2, 3]) # Always add all three nodes 16 | G.add_edges_from(edge_list) 17 | graphs[name] = G 18 | 19 | return graphs 20 | 21 | 22 | def render_all_triads(): 23 | """Render all triads using PHART and output in a grid-like format.""" 24 | triads = generate_triads() 25 | 26 | # Calculate the width needed for each diagram to create a grid effect 27 | sample_render = ASCIIRenderer( 28 | next(iter(triads.values())), node_style=NodeStyle.SQUARE 29 | ).render() 30 | width = max(len(line) for line in sample_render.split("\n")) + 4 # Add padding 31 | 32 | # We'll create 4 rows of 4 diagrams each 33 | output = [] 34 | current_row = [] 35 | 36 | for name, graph in triads.items(): 37 | renderer = ASCIIRenderer(graph, node_style=NodeStyle.SQUARE) 38 | rendered = renderer.render() 39 | 40 | # Add title above the diagram 41 | diagram_lines = [f"{name:^{width}}"] 42 | print(f"{diagram_lines}") 43 | diagram_lines.extend(line.ljust(width) for line in rendered.split("\n")) 44 | 45 | current_row.append(diagram_lines) 46 | 47 | if len(current_row) == 4: # Start new row after 4 diagrams 48 | # Combine diagrams in the row 49 | for i in range(len(current_row[0])): # For each line 50 | output.append("".join(diag[i] for diag in current_row)) 51 | output.append("") # Add blank line between rows 52 | current_row = [] 53 | 54 | # Handle any remaining diagrams in the last row 55 | if current_row: 56 | for i in range(len(current_row[0])): 57 | output.append("".join(diag[i] for diag in current_row)) 58 | 59 | return "\n".join(output) 60 | 61 | 62 | if __name__ == "__main__": 63 | print(render_all_triads()) 64 | -------------------------------------------------------------------------------- /examples/graph_examples.py: -------------------------------------------------------------------------------- 1 | # src path: examples\graph_examples.py 2 | from phart import ASCIIRenderer, NodeStyle, LayoutOptions 3 | import networkx as nx 4 | 5 | 6 | def example_binary_tree(): 7 | """Example of rendering a binary tree""" 8 | custom_decorators = { 9 | "N0": ("<<", ">>"), 10 | "N6": ("{{", "}}"), 11 | "N9": ("|", "|"), 12 | } 13 | print("\nBinary Tree Example:") 14 | # Create a binary tree (r=2) of height 3 15 | G = nx.balanced_tree(r=2, h=3, create_using=nx.DiGraph) 16 | # Relabel nodes to be more readable 17 | mapping = {i: f"N{i}" for i in G.nodes()} 18 | G = nx.relabel_nodes(G, mapping) 19 | 20 | renderer = ASCIIRenderer( 21 | G, 22 | node_style=NodeStyle.SQUARE, 23 | custom_decorators=custom_decorators, 24 | node_spacing=4, 25 | ) 26 | print(renderer.render()) 27 | 28 | for style in NodeStyle: 29 | print(f"\nUsing {style.name} style:") 30 | renderer = ASCIIRenderer( 31 | G, node_style=style, custom_decorators=custom_decorators, node_spacing=4 32 | ) 33 | print(renderer.render()) 34 | print("\n" + "=" * 50) # Add separator between styles 35 | 36 | 37 | def example_custom_decorators(): 38 | """Example of rendering a graph with custom node decorators""" 39 | print("\nCustom Decorators Example:") 40 | 41 | # Create a simple directed graph 42 | G = nx.DiGraph( 43 | [ 44 | ("Start", "Input Data"), 45 | ("Input Data", "Process"), 46 | ("Process", "Output"), 47 | ("Output", "End"), 48 | ] 49 | ) 50 | 51 | # Define custom decorators for specific nodes 52 | custom_decorators = { 53 | "Start": ("<", ">"), 54 | "End": ("{{", "}}"), 55 | } 56 | 57 | # Use custom decorators in the LayoutOptions 58 | options = LayoutOptions( 59 | node_style=NodeStyle.MINIMAL, 60 | custom_decorators=custom_decorators, 61 | node_spacing=4, 62 | layer_spacing=2, 63 | ) 64 | 65 | # Render the graph with custom node decorations 66 | renderer = ASCIIRenderer(G, options=options) 67 | print(renderer.render()) 68 | print("\n" + "=" * 50) # Separator for clarity 69 | 70 | 71 | def example_dependency_graph(): 72 | """Example of rendering a software dependency graph""" 73 | print("\nDependency Graph Example:") 74 | G = nx.DiGraph( 75 | [ 76 | ("main.py", "utils.py"), 77 | ("main.py", "config.py"), 78 | ("utils.py", "helpers.py"), 79 | ("utils.py", "constants.py"), 80 | ("config.py", "constants.py"), 81 | ("helpers.py", "constants.py"), 82 | ] 83 | ) 84 | 85 | # Use minimal style for better readability with long names 86 | options = LayoutOptions( 87 | node_style=NodeStyle.MINIMAL, node_spacing=6, layer_spacing=2 88 | ) 89 | renderer = ASCIIRenderer(G, options=options) 90 | print(renderer.render()) 91 | 92 | 93 | def example_workflow(): 94 | """Example of rendering a workflow/process diagram""" 95 | print("\nWorkflow Example:") 96 | G = nx.DiGraph( 97 | [ 98 | ("Start", "Input Data"), 99 | ("Input Data", "Validate"), 100 | ("Validate", "Process"), 101 | ("Process", "Error Check"), 102 | ("Error Check", "Process"), # Feedback loop 103 | ("Error Check", "Output"), 104 | ("Output", "End"), 105 | ] 106 | ) 107 | 108 | # Use round style for workflow nodes 109 | options = LayoutOptions(node_style=NodeStyle.ROUND, node_spacing=4, layer_spacing=2) 110 | renderer = ASCIIRenderer(G, options) 111 | print(renderer.render()) 112 | 113 | 114 | if __name__ == "__main__": 115 | example_binary_tree() 116 | # example_dependency_graph() 117 | # example_workflow() 118 | example_custom_decorators() 119 | -------------------------------------------------------------------------------- /examples/graphlet_generator.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | from phart import ASCIIRenderer, NodeStyle 3 | 4 | 5 | def generate_basic_graphlets(): 6 | """Generate a set of basic canonical graphlets.""" 7 | graphlets = {} 8 | 9 | # Single directed edge 10 | g = nx.DiGraph() 11 | g.add_edge("A", "B") 12 | graphlets["directed_edge"] = g 13 | 14 | # Bidirectional edge 15 | g = nx.DiGraph() 16 | g.add_edge("A", "B") 17 | g.add_edge("B", "A") 18 | graphlets["bidirectional"] = g 19 | 20 | # Triangle patterns 21 | # Directed cycle 22 | g = nx.DiGraph() 23 | g.add_edges_from([("A", "B"), ("B", "C"), ("C", "A")]) 24 | graphlets["triangle_cycle"] = g 25 | 26 | # Feed-forward triangle 27 | g = nx.DiGraph() 28 | g.add_edges_from([("A", "B"), ("B", "C"), ("A", "C")]) 29 | graphlets["triangle_feedforward"] = g 30 | 31 | # Square patterns 32 | # Directed cycle 33 | g = nx.DiGraph() 34 | g.add_edges_from([("A", "B"), ("B", "C"), ("C", "D"), ("D", "A")]) 35 | graphlets["square_cycle"] = g 36 | 37 | # Square with diagonals 38 | g = nx.DiGraph() 39 | g.add_edges_from( 40 | [("A", "B"), ("B", "C"), ("C", "D"), ("D", "A"), ("A", "C"), ("B", "D")] 41 | ) 42 | graphlets["square_cross"] = g 43 | 44 | return graphlets 45 | 46 | 47 | def test_render_graphlets(): 48 | """Test rendering of each graphlet.""" 49 | graphlets = generate_basic_graphlets() 50 | 51 | print("Testing basic graphlet renderings:") 52 | for name, g in graphlets.items(): 53 | print(f"\n{name}:") 54 | renderer = ASCIIRenderer(g, node_style=NodeStyle.SQUARE) 55 | print(renderer.render()) 56 | 57 | 58 | if __name__ == "__main__": 59 | test_render_graphlets() 60 | -------------------------------------------------------------------------------- /examples/networkx_integration.py: -------------------------------------------------------------------------------- 1 | """Examples of PHART visualization with various NetworkX graph types.""" 2 | # src path: examples\networkx_integration.py 3 | 4 | import networkx as nx 5 | from phart import ASCIIRenderer, NodeStyle, LayoutOptions 6 | 7 | 8 | def show_graph(G, title, style=NodeStyle.MINIMAL): 9 | """Helper to display a graph with title.""" 10 | options = LayoutOptions(node_style=style, node_spacing=6, layer_spacing=2) 11 | print(f"\n{title}") 12 | print("=" * len(title)) 13 | renderer = ASCIIRenderer(G, options=options) 14 | print(renderer.render()) 15 | 16 | 17 | def main(): 18 | # Basic graph types 19 | G = nx.path_graph(4, create_using=nx.DiGraph) 20 | show_graph(G, "Path Graph") 21 | 22 | G = nx.cycle_graph(5, create_using=nx.DiGraph) 23 | show_graph(G, "Cycle Graph") 24 | 25 | G = nx.star_graph(4, create_using=nx.Graph) 26 | show_graph(G, "Star Graph (Directed)", NodeStyle.SQUARE) 27 | 28 | # Trees and DAGs 29 | G = nx.balanced_tree(2, 3, create_using=nx.DiGraph) 30 | show_graph(G, "Balanced Binary Tree", NodeStyle.ROUND) 31 | 32 | G = nx.random_labeled_tree(10) 33 | show_graph(G, "Random Tree") 34 | 35 | # Special graphs 36 | G = nx.bull_graph() 37 | G = nx.DiGraph(G) # Convert to directed 38 | show_graph(G, "Bull Graph", NodeStyle.DIAMOND) 39 | 40 | G = nx.petersen_graph() 41 | G = nx.DiGraph(G) # Convert to directed 42 | show_graph(G, "Petersen Graph") 43 | 44 | G = nx.watts_strogatz_graph(8, 2, 0.2) 45 | show_graph(G, "Small World Graph (Watts-Strogatz)") 46 | 47 | # Real-world examples 48 | G = nx.karate_club_graph() 49 | G = nx.DiGraph(G) # Convert to directed 50 | show_graph(G, "Karate Club Social Network") 51 | 52 | 53 | if __name__ == "__main__": 54 | print("PHART + NetworkX Graph Examples") 55 | print("===============================") 56 | main() 57 | -------------------------------------------------------------------------------- /examples/showcase.py: -------------------------------------------------------------------------------- 1 | """Showcase of PHART's graph visualization capabilities.""" 2 | # src path: examples\showcase.py 3 | 4 | import networkx as nx 5 | from phart import ASCIIRenderer, NodeStyle, LayoutOptions 6 | 7 | 8 | def example_dependency_tree(): 9 | """Example of visualizing a software dependency tree.""" 10 | print("\nSoftware Dependency Example:") 11 | G = nx.DiGraph( 12 | [ 13 | ("main.py", "utils.py"), 14 | ("main.py", "config.py"), 15 | ("utils.py", "helpers.py"), 16 | ("utils.py", "constants.py"), 17 | ("config.py", "constants.py"), 18 | ("helpers.py", "constants.py"), 19 | ] 20 | ) 21 | options = LayoutOptions( 22 | node_style=NodeStyle.MINIMAL, node_spacing=6, layer_spacing=4 23 | ) 24 | renderer = ASCIIRenderer(G, options=options) 25 | print(renderer.render()) 26 | 27 | 28 | def example_hierarchical_org(): 29 | """Example of visualizing an organizational hierarchy.""" 30 | print("\nOrganizational Hierarchy Example:") 31 | G = nx.DiGraph( 32 | [ 33 | ("CEO", "CTO"), 34 | ("CEO", "CFO"), 35 | ("CEO", "COO"), 36 | ("CTO", "Dev Lead"), 37 | ("CTO", "Research Lead"), 38 | ("CFO", "Controller"), 39 | ("COO", "Sales Dir"), 40 | ("COO", "Marketing Dir"), 41 | ] 42 | ) 43 | 44 | options = LayoutOptions( 45 | node_style=NodeStyle.SQUARE, node_spacing=6, layer_spacing=4 46 | ) 47 | renderer = ASCIIRenderer(G, options=options) 48 | print(renderer.render()) 49 | 50 | 51 | def example_network_topology(): 52 | """Example of visualizing a network topology.""" 53 | print("\nNetwork Topology Example:") 54 | G = nx.DiGraph( 55 | [ 56 | ("Router1", "Switch1"), 57 | ("Router1", "Switch2"), 58 | ("Switch1", "Server1"), 59 | ("Switch1", "Server2"), 60 | ("Switch2", "Server3"), 61 | ("Switch2", "Server4"), 62 | ("Server1", "Server2"), # Cross-connection 63 | ("Server3", "Server4"), # Cross-connection 64 | ] 65 | ) 66 | 67 | options = LayoutOptions(node_style=NodeStyle.ROUND, node_spacing=5, layer_spacing=4) 68 | renderer = ASCIIRenderer(G, options=options) 69 | print(renderer.render()) 70 | 71 | 72 | def example_workflow(): 73 | """Example of visualizing a workflow/process diagram.""" 74 | print("\nWorkflow Example:") 75 | G = nx.DiGraph( 76 | [ 77 | ("Start", "Input"), 78 | ("Input", "Validate"), 79 | ("Validate", "Process"), 80 | ("Process", "Check"), 81 | ("Check", "Error"), 82 | ("Check", "Success"), 83 | ("Error", "Process"), # Feedback loop 84 | ("Success", "Output"), 85 | ("Output", "End"), 86 | ] 87 | ) 88 | 89 | options = LayoutOptions( 90 | node_style=NodeStyle.DIAMOND, node_spacing=4, layer_spacing=3 91 | ) 92 | renderer = ASCIIRenderer(G, options=options) 93 | print(renderer.render()) 94 | 95 | 96 | def example_dot_import(): 97 | """Example of importing from DOT format.""" 98 | print("\nDOT Import Example:") 99 | dot_string = """ 100 | digraph G { 101 | rankdir=LR; 102 | A -> B -> C; 103 | A -> D -> C; 104 | C -> E; 105 | } 106 | """ 107 | renderer = ASCIIRenderer.from_dot(dot_string) 108 | print(renderer.render()) 109 | 110 | 111 | if __name__ == "__main__": 112 | print("PHART Graph Visualization Examples") 113 | print("=================================") 114 | 115 | example_dependency_tree() 116 | example_hierarchical_org() 117 | example_network_topology() 118 | example_workflow() 119 | example_dot_import() 120 | 121 | print("\nCustom Styling Example:") 122 | print("Different node styles for the same balanced tree:") 123 | G = nx.balanced_tree(2, 2, create_using=nx.DiGraph) 124 | mapping = {i: f"{i}" for i in G.nodes()} 125 | G = nx.relabel_nodes(G, mapping) 126 | 127 | for style in NodeStyle: 128 | print(f"\nUsing {style.name} style:") 129 | renderer = ASCIIRenderer( 130 | G, 131 | node_style=style, 132 | custom_decorators={"0": ("<<", ">>"), "6": ("[[", "]]")}, 133 | ) 134 | renderer.draw() 135 | -------------------------------------------------------------------------------- /examples/simple_graph.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple examples demonstrating basic PHART usage. 3 | """ 4 | # src path: examples\simple_graph.py 5 | 6 | import networkx as nx 7 | from phart import ASCIIRenderer, NodeStyle 8 | 9 | 10 | def demonstrate_basic_graph(): 11 | """Simple directed graph example.""" 12 | print("\nBasic Directed Graph:") 13 | G = nx.DiGraph() 14 | G.add_edges_from([("A", "B"), ("A", "C"), ("B", "D"), ("C", "D")]) 15 | 16 | renderer = ASCIIRenderer(G) 17 | print(renderer.render()) 18 | 19 | 20 | def demonstrate_node_styles(): 21 | """Show different node style options.""" 22 | G = nx.balanced_tree(2, 2, create_using=nx.DiGraph) 23 | 24 | print("\nNode Styles:") 25 | for style in NodeStyle: 26 | if style.name not in "CUSTOM": 27 | print(f"\n{style.name} style:") 28 | renderer = ASCIIRenderer(G, node_style=style) 29 | print(renderer.render()) 30 | 31 | 32 | def demonstrate_cycle(): 33 | """Show how PHART handles cycles.""" 34 | print("\nGraph with Cycle:") 35 | G = nx.DiGraph( 36 | [ 37 | ("Start", "Process"), 38 | ("Process", "Check"), 39 | ("Check", "End"), 40 | ("Check", "Process"), # Creates cycle 41 | ] 42 | ) 43 | renderer = ASCIIRenderer(G) 44 | print(renderer.render()) 45 | 46 | 47 | if __name__ == "__main__": 48 | print("PHART Simple Examples") 49 | print("===================") 50 | 51 | demonstrate_basic_graph() 52 | demonstrate_node_styles() 53 | demonstrate_cycle() 54 | -------------------------------------------------------------------------------- /examples/triadic-census.py: -------------------------------------------------------------------------------- 1 | from phart import ASCIIRenderer, NodeStyle 2 | import networkx as nx 3 | 4 | 5 | def generate_triads(): 6 | """Generate all 16 possible directed triads with their standard naming.""" 7 | triads = { 8 | "003": [], # Empty triad 9 | "012": [(1, 2)], # Single edge 10 | "102": [(1, 2), (2, 1)], # Mutual edge 11 | "021D": [(3, 1), (3, 2)], # Two edges down 12 | "021U": [(1, 3), (2, 3)], # Two edges up 13 | "021C": [(1, 3), (3, 2)], # Two edges chain 14 | "111D": [(1, 2), (2, 1), (3, 1)], # Mutual + single down 15 | "111U": [(1, 2), (2, 1), (1, 3)], # Mutual + single up 16 | "030T": [(1, 2), (3, 2), (1, 3)], # Three edges triangle 17 | "030C": [(1, 3), (3, 2), (2, 1)], # Three edges cyclic 18 | "201": [(1, 2), (2, 1), (3, 1), (1, 3)], # Four edges 19 | "120D": [(1, 2), (2, 1), (3, 1), (3, 2)], # Four edges down 20 | "120U": [(1, 2), (2, 1), (1, 3), (2, 3)], # Four edges up 21 | "120C": [(1, 2), (2, 1), (1, 3), (3, 2)], # Four edges cycle 22 | "210": [(1, 2), (2, 1), (1, 3), (3, 2), (2, 3)], # Five edges 23 | "300": [(1, 2), (2, 1), (2, 3), (3, 2), (1, 3), (3, 1)], # Complete 24 | } 25 | 26 | graphs = {} 27 | for name, edge_list in triads.items(): 28 | G = nx.DiGraph() 29 | G.add_nodes_from([1, 2, 3]) # Always add all three nodes 30 | G.add_edges_from(edge_list) 31 | graphs[name] = G 32 | 33 | return graphs 34 | 35 | 36 | def render_all_triads(): 37 | """Render all triads using PHART and output in a grid-like format.""" 38 | triads = generate_triads() 39 | 40 | # Calculate the width needed for each diagram to create a grid effect 41 | sample_render = ASCIIRenderer( 42 | next(iter(triads.values())), node_style=NodeStyle.SQUARE 43 | ).render() 44 | width = max(len(line) for line in sample_render.split("\n")) + 4 # Add padding 45 | 46 | # We'll create 4 rows of 4 diagrams each 47 | output = [] 48 | current_row = [] 49 | 50 | for name, graph in triads.items(): 51 | renderer = ASCIIRenderer(graph, node_style=NodeStyle.SQUARE) 52 | rendered = renderer.render() 53 | 54 | # Add title above the diagram 55 | diagram_lines = [f"{name:^{width}}"] 56 | print(f"{diagram_lines}") 57 | diagram_lines.extend(line.ljust(width) for line in rendered.split("\n")) 58 | 59 | current_row.append(diagram_lines) 60 | 61 | if len(current_row) == 4: # Start new row after 4 diagrams 62 | # Combine diagrams in the row 63 | for i in range(len(current_row[0])): # For each line 64 | output.append("".join(diag[i] for diag in current_row)) 65 | output.append("") # Add blank line between rows 66 | current_row = [] 67 | 68 | # Handle any remaining diagrams in the last row 69 | if current_row: 70 | for i in range(len(current_row[0])): 71 | output.append("".join(diag[i] for diag in current_row)) 72 | 73 | return "\n".join(output) 74 | 75 | 76 | if __name__ == "__main__": 77 | print(render_all_triads()) 78 | G = nx.balanced_tree(2, 2, create_using=nx.DiGraph) 79 | mapping = {i: f"{i}" for i in G.nodes()} 80 | G = nx.relabel_nodes(G, mapping) 81 | 82 | for style in NodeStyle: 83 | print(f"\nUsing {style.name} style:") 84 | renderer = ASCIIRenderer( 85 | G, 86 | node_style=style, 87 | custom_decorators={"0": ("<<", ">>"), "6": ("[[", "]]")}, 88 | ) 89 | renderer.draw() 90 | -------------------------------------------------------------------------------- /mypy-phart.txt/index.txt: -------------------------------------------------------------------------------- 1 | Mypy Type Check Coverage Summary 2 | ================================ 3 | 4 | Script: index 5 | 6 | +----------------+-------------------+---------+ 7 | | Module | Imprecision | Lines | 8 | +----------------+-------------------+---------+ 9 | | phart | 0.00% imprecise | 5 LOC | 10 | | phart.encoding | 1.18% imprecise | 85 LOC | 11 | | phart.layout | 21.17% imprecise | 137 LOC | 12 | | phart.renderer | 4.86% imprecise | 329 LOC | 13 | | phart.styles | 0.00% imprecise | 137 LOC | 14 | +----------------+-------------------+---------+ 15 | | Total | 6.64% imprecise | 693 LOC | 16 | +----------------+-------------------+---------+ 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "phart" 7 | dynamic = ["version"] 8 | description = "Python Hierarchical ASCII Representation Tool - Pure Python graph visualization in ASCII, no external dependencies* (*except NetworkX)" 9 | requires-python= ">=3.10" 10 | readme = "README.md" 11 | license = "MIT" 12 | authors = [ 13 | { name = "Scott VR", email = "scottvr@gmail.com" } 14 | ] 15 | dependencies = [ 16 | "networkx>=3.3", 17 | ] 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Topic :: Software Development :: Libraries :: Python Modules", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Topic :: Text Processing :: Markup", 28 | ] 29 | [project.optional-dependencies] 30 | default = [ 31 | ] 32 | developer = [ 33 | 'pre-commit>=3.2', 34 | 'mypy>=1.1', 35 | 'isort', 36 | ] 37 | doc = [ 38 | ] 39 | example = [ 40 | 'pydot>=3.0.1', 41 | ] 42 | extra = [ 43 | 'pygraphviz>=1.14', 44 | ] 45 | test = [ 46 | 'pytest>=7.2', 47 | 'pytest-cov>=4.0', 48 | 'pydot' 49 | ] 50 | 51 | [project.scripts] 52 | phart = "phart.cli:main" 53 | 54 | [tool.mypy] 55 | python_version = "3.10" 56 | warn_return_any = true 57 | warn_unused_configs = true 58 | disallow_untyped_defs = true 59 | 60 | [tool.pytest.ini_options] 61 | testpaths = ["tests"] 62 | python_files = ["test_*.py"] 63 | addopts = "--cov=phart --cov-report html" 64 | filterwarnings = ["error"] 65 | 66 | [project.urls] 67 | Homepage = "https://github.com/scottvr/phart" 68 | Repository = "https://github.com/scottvr/phart.git" 69 | Issues = "https://github.com/scottvr/phart/issues" 70 | 71 | [tool.hatch.version] 72 | path = "src/phart/__init__.py" 73 | 74 | [tool.hatch.build.targets.wheel] 75 | packages = ["src/phart"] 76 | -------------------------------------------------------------------------------- /requirements/default.txt: -------------------------------------------------------------------------------- 1 | # Generated via tools/generate_requirements.py and pre-commit hook. 2 | # Do not edit this file; modify pyproject.toml instead. 3 | -------------------------------------------------------------------------------- /requirements/developer.txt: -------------------------------------------------------------------------------- 1 | # Generated via tools/generate_requirements.py and pre-commit hook. 2 | # Do not edit this file; modify pyproject.toml instead. 3 | pre-commit>=3.2 4 | mypy>=1.1 5 | isort 6 | -------------------------------------------------------------------------------- /requirements/doc.txt: -------------------------------------------------------------------------------- 1 | # Generated via tools/generate_requirements.py and pre-commit hook. 2 | # Do not edit this file; modify pyproject.toml instead. 3 | -------------------------------------------------------------------------------- /requirements/example.txt: -------------------------------------------------------------------------------- 1 | # Generated via tools/generate_requirements.py and pre-commit hook. 2 | # Do not edit this file; modify pyproject.toml instead. 3 | pydot>=3.0.1 4 | -------------------------------------------------------------------------------- /requirements/extra.txt: -------------------------------------------------------------------------------- 1 | # Generated via tools/generate_requirements.py and pre-commit hook. 2 | # Do not edit this file; modify pyproject.toml instead. 3 | pygraphviz>=1.14 4 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | # Generated via tools/generate_requirements.py and pre-commit hook. 2 | # Do not edit this file; modify pyproject.toml instead. 3 | pytest>=7.2 4 | pytest-cov>=4.0 5 | pydot 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = phart 3 | author = Scott VR 4 | description = Python Hierarchical ASCII Representation Tool 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/scottvr/phart 8 | project_urls = 9 | Bug Tracker = https://github.com/scottvr/phart/issues 10 | 11 | [isort] 12 | profile = black 13 | -------------------------------------------------------------------------------- /src/phart/__init__.py: -------------------------------------------------------------------------------- 1 | # src path: src\phart\__init__.py 2 | from .renderer import ASCIIRenderer 3 | from .styles import LayoutOptions, NodeStyle 4 | 5 | __version__ = "1.2.1" 6 | __all__ = ["ASCIIRenderer", "NodeStyle", "LayoutOptions"] 7 | -------------------------------------------------------------------------------- /src/phart/charset.py: -------------------------------------------------------------------------------- 1 | # src path: src\phart\charset.py 2 | # for adding new charsets and new cli charset features 3 | 4 | from enum import Enum 5 | 6 | 7 | class CharSet(Enum): 8 | """Character set options for rendering.""" 9 | 10 | ASCII = "ascii" # 7-bit ASCII characters only 11 | UNICODE = "unicode" # Unicode box drawing & arrows 12 | 13 | def __str__(self) -> str: 14 | return self.value 15 | -------------------------------------------------------------------------------- /src/phart/cli.py: -------------------------------------------------------------------------------- 1 | """Command line interface for PHART.""" 2 | # src path: src\phart\cli.py 3 | 4 | import sys 5 | import argparse 6 | import importlib.util 7 | from pathlib import Path 8 | from typing import Optional, Any 9 | 10 | from .renderer import ASCIIRenderer 11 | from .styles import NodeStyle, LayoutOptions 12 | from .charset import CharSet 13 | 14 | 15 | def parse_args() -> argparse.Namespace: 16 | """Parse command line arguments.""" 17 | parser = argparse.ArgumentParser( 18 | description="PHART: Python Hierarchical ASCII Rendering Tool" 19 | ) 20 | parser.add_argument( 21 | "input", type=Path, help="Input file (.dot, .graphml, or .py format)" 22 | ) 23 | parser.add_argument( 24 | "--output", 25 | "-o", 26 | type=Path, 27 | help="Output file (if not specified, prints to stdout)", 28 | ) 29 | parser.add_argument( 30 | "--style", 31 | choices=[s.name.lower() for s in NodeStyle], 32 | default="square", 33 | help="Node style (default: square)", 34 | ) 35 | parser.add_argument( 36 | "--node-spacing", 37 | type=int, 38 | default=4, 39 | help="Horizontal space between nodes (default: 4)", 40 | ) 41 | parser.add_argument( 42 | "--layer-spacing", 43 | type=int, 44 | default=2, 45 | help="Vertical space between layers (default: 2)", 46 | ) 47 | parser.add_argument( 48 | "--charset", 49 | type=CharSet, 50 | choices=list(CharSet), 51 | default=CharSet.UNICODE, 52 | help="Character set to use for rendering (default: unicode)", 53 | ) 54 | # Maintain backwards compatibility 55 | parser.add_argument( 56 | "--ascii", 57 | action="store_true", 58 | help="Force ASCII output (deprecated, use --charset ascii instead)", 59 | dest="use_legacy_ascii", 60 | ) 61 | parser.add_argument( 62 | "--function", 63 | "-f", 64 | type=str, 65 | help="Function to call in Python file (default: main)", 66 | default="main", 67 | ) 68 | 69 | return parser.parse_args() 70 | 71 | 72 | def load_python_module(file_path: Path) -> Any: 73 | """ 74 | Dynamically load a Python file as a module. 75 | 76 | Args: 77 | file_path: Path to Python file 78 | 79 | Returns: 80 | Loaded module object 81 | """ 82 | spec = importlib.util.spec_from_file_location("dynamic_module", file_path) 83 | if spec is None or spec.loader is None: 84 | raise ImportError(f"Could not load {file_path}") 85 | 86 | module = importlib.util.module_from_spec(spec) 87 | sys.modules["dynamic_module"] = module 88 | spec.loader.exec_module(module) 89 | return module 90 | 91 | 92 | def merge_layout_options( 93 | base: LayoutOptions, overrides: LayoutOptions 94 | ) -> LayoutOptions: 95 | """Merge two LayoutOptions, preserving custom decorators and other explicit settings.""" 96 | # Start with the base options 97 | merged = LayoutOptions( 98 | node_style=base.node_style, 99 | node_spacing=base.node_spacing, 100 | layer_spacing=base.layer_spacing, 101 | use_ascii=base.use_ascii, 102 | custom_decorators=base.custom_decorators.copy() 103 | if base.custom_decorators 104 | else None, 105 | ) 106 | 107 | # Override only non-None values from overrides 108 | if overrides.node_style is not None: 109 | merged.node_style = overrides.node_style 110 | if overrides.node_spacing is not None: 111 | merged.node_spacing = overrides.node_spacing 112 | if overrides.layer_spacing is not None: 113 | merged.layer_spacing = overrides.layer_spacing 114 | if overrides.use_ascii is not None: 115 | merged.use_ascii = overrides.use_ascii 116 | if overrides.custom_decorators is not None: 117 | # Merge custom decorators rather than replace 118 | if merged.custom_decorators is None: 119 | merged.custom_decorators = {} 120 | merged.custom_decorators.update(overrides.custom_decorators) 121 | 122 | return merged 123 | 124 | 125 | def create_layout_options(args: argparse.Namespace) -> LayoutOptions: 126 | """Create LayoutOptions from CLI arguments.""" 127 | return LayoutOptions( 128 | node_style=NodeStyle[args.style.upper()], 129 | node_spacing=args.node_spacing, 130 | layer_spacing=args.layer_spacing, 131 | use_ascii=(args.charset == CharSet.ASCII or args.use_legacy_ascii), 132 | ) 133 | 134 | 135 | def main() -> Optional[int]: 136 | """CLI entry point for PHART.""" 137 | args = parse_args() 138 | 139 | try: 140 | if args.input.suffix == ".py": 141 | # Handle Python file 142 | module = load_python_module(args.input) 143 | 144 | # Create default layout options from CLI args 145 | cli_options = create_layout_options(args) 146 | 147 | # Instead of directly setting default_options 148 | # ASCIIRenderer.default_options = options 149 | 150 | # Set up a merger that will preserve custom settings 151 | def option_merger( 152 | instance_options: Optional[LayoutOptions] = None, 153 | ) -> LayoutOptions: 154 | if instance_options is None: 155 | return cli_options 156 | return merge_layout_options(instance_options, cli_options) 157 | 158 | ASCIIRenderer.default_options = option_merger() 159 | 160 | try: 161 | if args.function != "main": 162 | func = getattr(module, args.function) 163 | func() 164 | else: 165 | if hasattr(module, "main"): 166 | module.main() 167 | else: 168 | # Simulate __main__ execution 169 | original_name = module.__name__ 170 | module.__name__ = "__main__" 171 | # Re-execute the module with __name__ == "__main__" 172 | 173 | spec = importlib.util.spec_from_file_location( 174 | "__main__", args.input 175 | ) 176 | if spec is None or spec.loader is None: 177 | raise ImportError(f"Could not load {args.input}") 178 | 179 | spec.loader.exec_module(module) 180 | module.__name__ = original_name 181 | 182 | except AttributeError: 183 | if args.function != "main": 184 | print( 185 | f"Error: Function '{args.function}' not found in {args.input}", 186 | file=sys.stderr, 187 | ) 188 | print( 189 | f"Error: No main() function or __main__ block found in {args.input}", 190 | file=sys.stderr, 191 | ) 192 | return 1 193 | 194 | else: 195 | # Read input file content 196 | with open(args.input, "r", encoding="utf-8") as f: 197 | content = f.read() 198 | 199 | # Try to determine format from content 200 | try: 201 | # Try GraphML first (XML format) 202 | if content.strip().startswith(" int: 21 | """Calculate display width of a node including decorators. 22 | 23 | Parameters 24 | ---------- 25 | node : str 26 | Node identifier 27 | 28 | Returns 29 | ------- 30 | int 31 | Total width of node when rendered 32 | """ 33 | prefix, suffix = self.options.get_node_decorators(node) 34 | return len(str(node)) + len(str(prefix)) + len(str(suffix)) 35 | 36 | def _calculate_node_importance(self, graph: nx.DiGraph, node: Any) -> float: 37 | """ 38 | Calculate node importance based on multiple factors. 39 | 40 | Higher score = more important node that should be positioned prominently. 41 | Factors: 42 | - Degree centrality (both in and out) 43 | - Number of bidirectional relationships 44 | - Position in any cycles 45 | """ 46 | # Get basic degree information 47 | in_degree = graph.in_degree(node) 48 | out_degree = graph.out_degree(node) 49 | 50 | # Count bidirectional relationships 51 | bidir_count = sum( 52 | 1 for nbr in graph.neighbors(node) if graph.has_edge(nbr, node) 53 | ) 54 | 55 | # Calculate score based on: 56 | # - Overall connectivity (degrees) 57 | # - Bidirectional relationships (weighted more heavily) 58 | # - Balance of in/out edges 59 | score = ( 60 | in_degree 61 | + out_degree # Basic connectivity 62 | + bidir_count * 2 # Bidirectional relationships (weighted) 63 | + -abs(in_degree - out_degree) 64 | ) # Penalize imbalanced in/out 65 | 66 | return score 67 | 68 | def _should_use_vertical_layout(self, graph: nx.DiGraph) -> bool: 69 | """ 70 | Determine if graph should use vertical layout based on edge density and patterns. 71 | 72 | A vertical layout (one node top, others below) is preferred when: 73 | - Graph is dense (many edges relative to possible edges) 74 | - Has significant bidirectional relationships 75 | """ 76 | if len(graph) != 3: 77 | return False 78 | 79 | # Calculate edge density 80 | possible_edges = len(graph) * (len(graph) - 1) # For directed graph 81 | actual_edges = len(graph.edges()) 82 | density = actual_edges / possible_edges 83 | 84 | # Count bidirectional relationships 85 | bidir_count = ( 86 | sum(1 for u, v in graph.edges() if graph.has_edge(v, u)) // 2 87 | ) # Divide by 2 as each bidir edge is counted twice 88 | 89 | # Use vertical layout if: 90 | # - Dense (>50% of possible edges) OR 91 | # - Has multiple bidirectional relationships 92 | return density > 0.5 or bidir_count > 1 93 | 94 | def _layout_vertical( 95 | self, graph: nx.DiGraph, spacing: int 96 | ) -> Dict[str, Tuple[int, int]]: 97 | """ 98 | Layout graph vertically with most important node on top. 99 | 100 | Prioritizes: 101 | - Edge visibility 102 | - Minimal crossings 103 | - Clear bidirectional relationships 104 | """ 105 | positions = {} 106 | nodes = list(graph.nodes()) 107 | 108 | # Score nodes by importance 109 | node_scores = { 110 | node: self._calculate_node_importance(graph, node) for node in nodes 111 | } 112 | 113 | # Choose top node (highest score) 114 | top_node = max(nodes, key=lambda n: node_scores[n]) 115 | 116 | # Get remaining nodes 117 | bottom_nodes = [n for n in nodes if n != top_node] 118 | 119 | # Sort bottom nodes by their relationships with top node 120 | bottom_nodes.sort( 121 | key=lambda n: ( 122 | graph.has_edge(top_node, n), # Edges from top 123 | graph.has_edge(n, top_node), # Edges to top 124 | node_scores[n], # Overall importance 125 | ), 126 | reverse=True, 127 | ) 128 | 129 | # Calculate node widths 130 | widths = {node: self._get_node_width(str(node)) for node in nodes} 131 | 132 | # Calculate total width needed 133 | bottom_width = widths[bottom_nodes[0]] + widths[bottom_nodes[1]] + spacing 134 | 135 | # Position nodes with proper centering 136 | top_x = (bottom_width - widths[top_node]) // 2 137 | 138 | # Use layer_spacing for vertical distance (like hierarchical layout) 139 | layer_height = ( 140 | 1 if self.options.layer_spacing == 0 else self.options.layer_spacing 141 | ) 142 | 143 | positions[top_node] = (top_x, 0) 144 | 145 | # Position bottom nodes using consistent layer height 146 | current_x = 0 147 | positions[bottom_nodes[0]] = (current_x, layer_height) 148 | current_x += widths[bottom_nodes[0]] + spacing 149 | positions[bottom_nodes[1]] = (current_x, layer_height) 150 | 151 | return positions 152 | 153 | def calculate_layout(self) -> Tuple[Dict[str, Tuple[int, int]], int, int]: 154 | """Calculate node positions using layout appropriate for graph structure.""" 155 | if not self.graph: 156 | return {}, 0, 0 157 | 158 | effective_spacing = self.options.get_effective_node_spacing(has_edges=True) 159 | 160 | # For directed graphs with 3 nodes, check if we should use vertical layout 161 | # Get positions using appropriate layout method 162 | if ( 163 | isinstance(self.graph, nx.DiGraph) 164 | and len(self.graph) == 3 165 | and self._should_use_vertical_layout(self.graph) 166 | ): 167 | positions = self._layout_vertical(self.graph, effective_spacing) 168 | else: 169 | # Fallback to standard hierarchical layout 170 | positions = self._layout_hierarchical(self.graph, effective_spacing) 171 | 172 | # Calculate base dimensions from positions 173 | # Ensure minimum width for node display 174 | node_widths = [self._get_node_width(str(node)) for node in self.graph.nodes()] 175 | min_width = max(node_widths) if node_widths else 0 176 | 177 | base_width = max(min_width, max((x for x, _ in positions.values()), default=0)) 178 | 179 | # Ensure we have at least enough height for the nodes 180 | base_height = max(y for _, y in positions.values()) if positions else 0 181 | if base_height == 0 and positions: # If we have nodes but no height 182 | base_height = self.options.layer_spacing # Use at least one layer of height 183 | 184 | return positions, base_width, base_height 185 | 186 | def _layout_hierarchical( 187 | self, graph: nx.Graph, spacing: int 188 | ) -> Dict[str, Tuple[int, int]]: 189 | """Position nodes in a hierarchical layout preserving layers. 190 | 191 | This is the standard layout for non-triad cases, organizing nodes into 192 | clear hierarchical layers based on graph structure. 193 | """ 194 | if graph.is_directed(): 195 | roots = [n for n, d in graph.in_degree() if d == 0] 196 | if not roots: 197 | # If no clear root, use node with highest out-degree 198 | root = max(graph.nodes(), key=lambda n: graph.out_degree(n)) 199 | roots = [root] 200 | else: 201 | # For undirected, use degree centrality 202 | root = max(graph.nodes(), key=lambda n: graph.degree(n)) 203 | roots = [root] 204 | 205 | # Calculate distances from roots to organize layers 206 | distances: Dict[str, int] = {} 207 | for root in roots: 208 | lengths = nx.single_source_shortest_path_length(graph, root) 209 | for node, dist in lengths.items(): 210 | distances[node] = min(distances.get(node, dist), dist) 211 | 212 | # Group nodes by layer 213 | layers: Dict[int, Set[str]] = {} 214 | for node, layer in distances.items(): 215 | if layer not in layers: 216 | layers[layer] = set() 217 | layers[layer].add(node) 218 | 219 | # Calculate positions preserving layer structure 220 | positions = {} 221 | layer_widths = {} 222 | 223 | # Calculate width needed for each layer 224 | for layer, nodes in layers.items(): 225 | total_width = sum(self._get_node_width(str(n)) for n in nodes) 226 | total_spacing = (len(nodes) - 1) * spacing 227 | layer_widths[layer] = total_width + total_spacing 228 | 229 | max_width = max(layer_widths.values()) if layer_widths else 0 230 | 231 | # Position nodes within their layers 232 | for layer, nodes in layers.items(): 233 | y = layer * ( 234 | 1 if self.options.layer_spacing == 0 else self.options.layer_spacing 235 | ) 236 | total_width = layer_widths[layer] 237 | start_x = (max_width - total_width) // 2 238 | current_x = start_x 239 | 240 | for node in sorted(nodes): 241 | positions[node] = (current_x, y) 242 | current_x += self._get_node_width(str(node)) + spacing 243 | 244 | return ( 245 | positions # Just return positions, let calculate_layout handle dimensions 246 | ) 247 | 248 | def calculate_canvas_dimensions( 249 | self, positions: Dict[str, Tuple[int, int]] 250 | ) -> Tuple[int, int]: 251 | """Calculate required canvas dimensions based on layout and node decorations.""" 252 | if not positions: 253 | return 0, 0 254 | 255 | # Calculate width needed for nodes and decorations 256 | max_node_end = 0 257 | for node, (x, y) in positions.items(): 258 | node_width = sum( 259 | len(part) for part in self.options.get_node_decorators(str(node)) 260 | ) + len(str(node)) 261 | node_end = x + node_width 262 | max_node_end = max(max_node_end, node_end) 263 | 264 | # Add configured padding plus extra space for edge decorators 265 | extra_edge_space = ( 266 | 6 if self.options.use_ascii else 4 267 | ) # ASCII needs more space for markers 268 | final_width = max( 269 | 1, max_node_end + self.options.right_padding + extra_edge_space 270 | ) 271 | 272 | # Calculate height including padding, ensuring minimum height 273 | max_y = max(y for _, y in positions.values()) 274 | final_height = max(1, max_y + 2) # Ensure at least height of 1 275 | 276 | return final_width, final_height 277 | -------------------------------------------------------------------------------- /src/phart/renderer.py: -------------------------------------------------------------------------------- 1 | """ 2 | ***** 3 | PHART 4 | ***** 5 | 6 | Python Hierarchical ASCII Rendering Tool for graphs. 7 | 8 | This module provides functionality for rendering graphs as ASCII art, with particular 9 | emphasis on dependency visualization and hierarchical structures. 10 | 11 | The PHART renderer can visualize: 12 | * NetworkX graphs 13 | * DOT format graphs 14 | * GraphML files 15 | * Dependency structures 16 | 17 | Examples 18 | -------- 19 | >>> import networkx as nx 20 | >>> from phart import ASCIIRenderer 21 | >>> 22 | >>> # Simple path graph 23 | >>> G = nx.path_graph(3) 24 | >>> renderer = ASCIIRenderer(G) 25 | >>> print(renderer.render()) 26 | 1 27 | | 28 | 2 29 | | 30 | 3 31 | 32 | >>> # Directed graph with custom node style 33 | >>> G = nx.DiGraph([('A', 'B'), ('A', 'C'), ('B', 'D'), ('C', 'D')]) 34 | >>> renderer = ASCIIRenderer(G, node_style=NodeStyle.SQUARE) 35 | >>> print(renderer.render()) 36 | [A] 37 | | 38 | ---|--- 39 | | | 40 | [B] [C] 41 | | | 42 | | | 43 | --[D]-- 44 | 45 | Notes 46 | ----- 47 | While this module can work with any NetworkX graph, it is optimized for: 48 | * Directed acyclic graphs (DAGs) 49 | * Dependency trees 50 | * Hierarchical structures 51 | 52 | For dense or highly connected graphs, the ASCII representation may become 53 | cluttered. Consider using dedicated visualization tools for such cases. 54 | 55 | See Also 56 | -------- 57 | * NetworkX: https://networkx.org/ 58 | * Graphviz: https://graphviz.org/ 59 | """ 60 | # src path: src\phart\renderer.py 61 | 62 | from typing import Any, Dict, List, Optional, TextIO, Tuple 63 | 64 | import networkx as nx # type: ignore 65 | 66 | from .layout import LayoutManager 67 | from .styles import LayoutOptions, NodeStyle 68 | 69 | import sys 70 | import io 71 | 72 | 73 | class ASCIIRenderer: 74 | """ 75 | ASCII art renderer for graphs. 76 | 77 | This class provides functionality to render graphs as ASCII art, with 78 | support for different node styles and layout options. 79 | 80 | Parameters 81 | ---------- 82 | graph : NetworkX graph 83 | The graph to render 84 | node_style : NodeStyle, optional (default=NodeStyle.MINIMAL) 85 | Style for node representation 86 | node_spacing : int, optional (default=4) 87 | Minimum horizontal space between nodes 88 | layer_spacing : int, optional (default=2) 89 | Number of lines between layers 90 | 91 | Attributes 92 | ---------- 93 | graph : NetworkX graph 94 | The graph being rendered 95 | options : LayoutOptions 96 | Layout and style configuration 97 | 98 | Examples 99 | -------- 100 | >>> import networkx as nx 101 | >>> G = nx.DiGraph([('A', 'B'), ('B', 'C')]) 102 | >>> renderer = ASCIIRenderer(G) 103 | >>> print(renderer.render()) 104 | A 105 | | 106 | B 107 | | 108 | C 109 | 110 | See Also 111 | -------- 112 | render : Render the graph as ASCII art 113 | from_dot : Create renderer from DOT format 114 | """ 115 | 116 | @staticmethod 117 | def _is_redirected() -> bool: 118 | """Check if output is being redirected.""" 119 | if sys.platform == "win32": 120 | import msvcrt 121 | import ctypes 122 | 123 | try: 124 | fileno = sys.stdout.fileno() 125 | handle = msvcrt.get_osfhandle(fileno) 126 | return not bool(ctypes.windll.kernel32.GetConsoleMode(handle, None)) 127 | except OSError: 128 | return True 129 | except AttributeError: 130 | return True 131 | return not sys.stdout.isatty() 132 | 133 | @staticmethod 134 | def _can_use_unicode() -> bool: 135 | """Internal check for Unicode support.""" 136 | if sys.platform == "win32": 137 | try: 138 | import ctypes 139 | 140 | kernel32 = ctypes.windll.kernel32 141 | return bool(kernel32.GetConsoleOutputCP() == 65001) 142 | except BaseException: 143 | return False 144 | return True 145 | 146 | default_options: Optional[LayoutOptions] = None 147 | 148 | def __init__( 149 | self, 150 | graph: nx.Graph, 151 | *, # Force keyword args after this 152 | node_style: NodeStyle = NodeStyle.SQUARE, 153 | node_spacing: int = 4, 154 | layer_spacing: int = 2, 155 | use_ascii: Optional[bool] = None, 156 | custom_decorators: Optional[Dict[str, Tuple[str, str]]] = None, 157 | options: Optional[LayoutOptions] = None, 158 | ) -> None: 159 | """Initialize the ASCII renderer. 160 | 161 | Args: 162 | graph: The networkx graph to render 163 | node_style: Style for nodes (must be passed as keyword arg) 164 | node_spacing: Horizontal spacing between nodes (must be passed as keyword arg) 165 | layer_spacing: Vertical spacing between layers (must be passed as keyword arg) 166 | use_ascii: Force ASCII output (must be passed as keyword arg) 167 | custom_decorators: Custom node decorations (must be passed as keyword arg) 168 | options: LayoutOptions instance (must be passed as keyword arg) 169 | """ 170 | self.graph = graph 171 | 172 | if options is not None and options.use_ascii is not None: 173 | use_ascii = options.use_ascii 174 | elif use_ascii is None: 175 | use_ascii = not self._can_use_unicode() 176 | 177 | if options is not None: 178 | self.options = options 179 | self.options.use_ascii = use_ascii 180 | if custom_decorators is not None: 181 | self.options.custom_decorators = custom_decorators 182 | # Make sure node_style is properly set to just the style enum 183 | if isinstance(self.options, LayoutOptions): 184 | self.options.node_style = self.options.node_style 185 | elif self.default_options is not None: 186 | self.options = self.default_options 187 | else: 188 | self.options = LayoutOptions( 189 | node_style=node_style, 190 | node_spacing=node_spacing, 191 | layer_spacing=layer_spacing, 192 | use_ascii=use_ascii, 193 | custom_decorators=custom_decorators, 194 | ) 195 | self.layout_manager = LayoutManager(graph, self.options) 196 | self.canvas: List[List[str]] = [] 197 | 198 | def _ensure_encoding(self, text: str) -> str: 199 | """Internal method to handle encoding safely.""" 200 | try: 201 | return text.encode("utf-8").decode("utf-8") 202 | except UnicodeEncodeError: 203 | return text.encode("ascii", errors="replace").decode("ascii") 204 | 205 | def render(self, print_config: Optional[bool] = False) -> str: 206 | """Render the graph as ASCII art.""" 207 | positions, width, height = self.layout_manager.calculate_layout() 208 | if not positions: 209 | return "" 210 | 211 | # Initialize canvas with adjusted positions 212 | self._init_canvas(width, height, positions) 213 | 214 | # Only try to draw edges if we have any 215 | if self.graph.edges(): 216 | for start, end in self.graph.edges(): 217 | if start in positions and end in positions: 218 | try: 219 | self._draw_edge(start, end, positions) 220 | except IndexError as e: 221 | # For debugging, print more info about what failed 222 | pos_info = ( 223 | f"start_pos={positions[start]}, end_pos={positions[end]}" 224 | ) 225 | edge_info = f"edge={start}->{end}" 226 | canvas_info = f"canvas={len(self.canvas)}x{len(self.canvas[0])}" 227 | raise IndexError( 228 | f"Edge drawing failed: {edge_info}, {pos_info}, {canvas_info}" 229 | ) from e 230 | 231 | # Draw nodes 232 | for node, (x, y) in positions.items(): 233 | prefix, suffix = self.options.get_node_decorators(str(node)) 234 | label = f"{prefix}{node}{suffix}" 235 | for i, char in enumerate(label): 236 | try: 237 | self.canvas[y][x + i] = char 238 | except IndexError as e: 239 | pos_info = f"pos=({x},{y}), i={i}, label={label}" 240 | canvas_info = f"canvas={len(self.canvas)}x{len(self.canvas[0])}" 241 | raise IndexError( 242 | f"Node drawing failed: {pos_info}, {canvas_info}" 243 | ) from e 244 | 245 | return "\n".join("".join(row).rstrip() for row in self.canvas) 246 | 247 | def draw(self, file: Optional[TextIO] = None) -> None: 248 | """ 249 | Draw the graph to a file or stdout. 250 | 251 | Parameters 252 | ---------- 253 | file : Optional[TextIO] 254 | File to write to. If None, writes to stdout 255 | """ 256 | 257 | is_redirected = self._is_redirected() if file is None else False 258 | 259 | if file is None: 260 | if is_redirected or self.options.use_ascii: 261 | # Use ASCII when redirected or explicitly requested 262 | old_use_ascii = self.options.use_ascii 263 | self.options.use_ascii = True 264 | try: 265 | print(self.render(), file=sys.stdout) 266 | finally: 267 | self.options.use_ascii = old_use_ascii 268 | else: 269 | # Direct to console, try Unicode 270 | sys.stdout = io.TextIOWrapper( 271 | sys.stdout.buffer, encoding="utf-8", errors="replace" 272 | ) 273 | print(self.render(), file=sys.stdout) 274 | else: 275 | print(self.render(), file=file) 276 | 277 | def write_to_file(self, filename: str) -> None: 278 | """ 279 | Write graph representation to a file. 280 | 281 | Parameters 282 | ---------- 283 | filename : str 284 | Path to output file 285 | """ 286 | 287 | with open(filename, "w", encoding="utf-8") as f: 288 | f.write(self.render()) 289 | 290 | def _init_canvas( 291 | self, width: int, height: int, positions: Dict[str, Tuple[int, int]] 292 | ) -> None: 293 | """ 294 | Initialize blank canvas with given dimensions. 295 | 296 | Args: 297 | width: Canvas width in characters 298 | height: Canvas height in characters 299 | positions: Node positions (kept for API compatibility) 300 | 301 | Raises: 302 | ValueError: If dimensions are negative 303 | """ 304 | # Calculate minimum dimensions needed 305 | max_x_pos = max(x for x, _ in positions.values()) if positions else 0 306 | max_y_pos = max(y for _, y in positions.values()) if positions else 0 307 | 308 | # Calculate width needed for widest node plus its position 309 | max_node_width = ( 310 | max( 311 | sum(len(part) for part in self.options.get_node_decorators(str(node))) 312 | + len(str(node)) 313 | for node in positions.keys() 314 | ) 315 | if positions 316 | else 1 317 | ) 318 | 319 | # Ensure minimum dimensions that can hold all nodes and edges 320 | min_width = max_x_pos + max_node_width + 1 # +1 for safety margin 321 | min_height = max_y_pos + 3 # +3 to ensure room for edges between layers 322 | 323 | final_width = max(width, min_width) 324 | final_height = max(height, min_height) 325 | 326 | if final_width < 0 or final_height < 0: 327 | raise ValueError( 328 | f"Canvas dimensions must not be negative (got {width}x{height})" 329 | ) 330 | 331 | self.canvas = [[" " for _ in range(final_width)] for _ in range(final_height)] 332 | 333 | def _draw_vertical_segment(self, x, start_y, end_y, marker=None): 334 | for y in range(start_y + 1, end_y): 335 | self.canvas[y][x] = self.options.edge_vertical 336 | if marker: 337 | mid_y = (start_y + end_y) // 2 338 | self.canvas[mid_y][x] = marker 339 | 340 | def _draw_horizontal_segment(self, y, start_x, end_x, marker=None): 341 | for x in range(start_x + 1, end_x): 342 | self.canvas[y][x] = self.options.edge_horizontal 343 | if marker: 344 | mid_x = (start_x + end_x) // 2 345 | self.canvas[y][mid_x] = marker 346 | 347 | def _safe_draw(self, x, y, char): 348 | try: 349 | self.canvas[y][x] = char 350 | except IndexError: 351 | raise IndexError(f"Drawing exceeded canvas bounds at ({x}, {y})") 352 | 353 | def _is_terminal( 354 | self, positions: Dict[str, Tuple[int, int]], node: str, x: int, y: int 355 | ) -> bool: 356 | """ 357 | Check if a position represents a node connection point. 358 | 359 | A terminal is the point where an edge connects to a node, typically the 360 | node's center point on its boundary. 361 | """ 362 | if node not in positions: 363 | return False 364 | node_x, node_y = positions[node] 365 | prefix, _ = self.options.get_node_decorators(str(node)) 366 | node_width = len(str(node)) + len(str(prefix)) 367 | node_center = node_x + node_width // 2 368 | 369 | return y == node_y and x == node_center 370 | 371 | def _draw_direction( 372 | self, y: int, x: int, direction: str, is_terminal: bool = False 373 | ) -> None: 374 | """ 375 | Draw a directional indicator, respecting terminal points. 376 | 377 | Terminal points always show direction as they represent actual node connections. 378 | Non-terminal points preserve existing directional indicators to maintain path clarity. 379 | """ 380 | if is_terminal: 381 | # Always show direction at node connection points 382 | self.canvas[y][x] = direction 383 | elif self.canvas[y][x] not in ( 384 | self.options.edge_arrow_up, 385 | self.options.edge_arrow_down, 386 | ): 387 | # Only draw direction on non-terminals if there isn't already a direction 388 | self.canvas[y][x] = direction 389 | 390 | def _draw_edge( 391 | self, start: str, end: str, positions: Dict[str, Tuple[int, int]] 392 | ) -> None: 393 | """Draw an edge between two nodes on the canvas.""" 394 | if start not in positions or end not in positions: 395 | raise KeyError( 396 | f"Node position not found: {start if start not in positions else end}" 397 | ) 398 | 399 | start_x, start_y = positions[start] 400 | end_x, end_y = positions[end] 401 | 402 | # Calculate node widths for edge positioning 403 | prefix, _ = self.options.get_node_decorators(str(start)) 404 | start_width = len(str(start)) + len(str(prefix)) 405 | end_width = len(str(end)) + len(str(prefix)) 406 | 407 | start_center = start_x + start_width // 2 408 | end_center = end_x + end_width // 2 409 | 410 | # Check if this is a bidirectional edge 411 | is_bidirectional = ( 412 | not self.graph.is_directed() or (end, start) in self.graph.edges() 413 | ) 414 | 415 | try: 416 | # Case 1: Same level horizontal connection 417 | if start_y == end_y: 418 | min_x = min(start_center, end_center) 419 | max_x = max(start_center, end_center) 420 | for x in range(min_x + 1, max_x): 421 | self.canvas[start_y][x] = self.options.edge_horizontal 422 | if is_bidirectional: 423 | self.canvas[start_y][min_x + 1] = self.options.edge_arrow_r 424 | self.canvas[start_y][max_x - 1] = self.options.edge_arrow_l 425 | else: 426 | if start_center < end_center: 427 | self.canvas[start_y][max_x - 1] = self.options.edge_arrow_r 428 | else: 429 | self.canvas[start_y][min_x + 1] = self.options.edge_arrow_l 430 | 431 | # Case 2: Top to bottom connection 432 | elif start_y < end_y or end_y < start_y: 433 | # Identify top and bottom nodes 434 | top_node = start if start_y < end_y else end 435 | bottom_node = end if start_y < end_y else start 436 | top_x, top_y = positions[top_node] 437 | bottom_x, bottom_y = positions[bottom_node] 438 | 439 | # Calculate centers 440 | top_center = top_x + (len(str(top_node)) + len(str(prefix))) // 2 441 | bottom_center = ( 442 | bottom_x + (len(str(bottom_node)) + len(str(prefix))) // 2 443 | ) 444 | 445 | # Draw horizontal segment from top node to vertical drop point 446 | min_x = min(top_center, bottom_center) 447 | max_x = max(top_center, bottom_center) 448 | y = top_y 449 | for x in range(min_x + 1, max_x): 450 | self.canvas[y][x] = self.options.edge_horizontal 451 | 452 | # Add crossing point 453 | self.canvas[y][bottom_center] = self.options.edge_cross 454 | 455 | # Draw vertical segment 456 | for y in range(top_y + 1, bottom_y): 457 | self.canvas[y][bottom_center] = self.options.edge_vertical 458 | 459 | # Add direction indicators 460 | if is_bidirectional: 461 | # Place arrows at both ends 462 | self.canvas[top_y + 1][bottom_center] = self.options.edge_arrow_up 463 | self.canvas[bottom_y - 1][bottom_center] = ( 464 | self.options.edge_arrow_down 465 | ) 466 | else: 467 | # Add arrow based on direction 468 | if start_y < end_y: # Top to bottom 469 | self.canvas[top_y + 1][bottom_center] = ( 470 | self.options.edge_arrow_up 471 | ) 472 | else: # Bottom to top 473 | self.canvas[bottom_y - 1][bottom_center] = ( 474 | self.options.edge_arrow_down 475 | ) 476 | 477 | except IndexError as e: 478 | raise IndexError(f"Edge drawing exceeded canvas boundaries: {e}") 479 | 480 | 481 | @classmethod 482 | def from_dot(cls, dot_string: str, **kwargs: Any) -> "ASCIIRenderer": 483 | """ 484 | Create a renderer from a DOT format string. 485 | 486 | Parameters 487 | ---------- 488 | dot_string : str 489 | Graph description in DOT format 490 | **kwargs 491 | Additional arguments passed to the constructor 492 | 493 | Returns 494 | ------- 495 | ASCIIRenderer 496 | New renderer instance 497 | 498 | Raises 499 | ------ 500 | ImportError 501 | If pydot is not available 502 | ValueError 503 | If DOT string doesn't contain any valid graphs 504 | 505 | Examples 506 | -------- 507 | >>> dot = ''' 508 | ... digraph { 509 | ... A -> B 510 | ... B -> C 511 | ... } 512 | ... ''' 513 | >>> renderer = ASCIIRenderer.from_dot(dot) 514 | >>> print(renderer.render()) 515 | A 516 | | 517 | B 518 | | 519 | C 520 | """ 521 | 522 | try: 523 | import pydot # type: ignore 524 | except ImportError: 525 | raise ImportError("pydot is required for DOT format support") 526 | 527 | graphs = pydot.graph_from_dot_data(dot_string) 528 | if not graphs: 529 | raise ValueError("No valid graphs found in DOT string") 530 | 531 | # Take first graph from the list 532 | G = nx.nx_pydot.from_pydot(graphs[0]) 533 | if not isinstance(G, nx.DiGraph): 534 | G = nx.DiGraph(G) 535 | return cls(G, **kwargs) 536 | 537 | 538 | @classmethod 539 | def from_graphml(cls, graphml_file: str, **kwargs: Any) -> "ASCIIRenderer": 540 | """ 541 | Create a renderer from a GraphML file. 542 | 543 | Parameters 544 | ---------- 545 | graphml_file : str 546 | Path to GraphML file 547 | **kwargs 548 | Additional arguments passed to the constructor 549 | 550 | Returns 551 | ------- 552 | ASCIIRenderer 553 | New renderer instance 554 | 555 | Raises 556 | ------ 557 | ImportError 558 | If NetworkX graphml support is not available 559 | ValueError 560 | If file cannot be read as GraphML 561 | """ 562 | try: 563 | G = nx.read_graphml(graphml_file) 564 | if not isinstance(G, nx.DiGraph): 565 | G = nx.DiGraph(G) 566 | return cls(G, **kwargs) 567 | except Exception as e: 568 | raise ValueError(f"Failed to read GraphML file: {e}") 569 | -------------------------------------------------------------------------------- /src/phart/styles.py: -------------------------------------------------------------------------------- 1 | # src path: src\phart\styles.py 2 | from dataclasses import dataclass, field, fields 3 | from typing import Tuple, Any, Optional, Union, Dict 4 | from enum import Enum 5 | 6 | 7 | class NodeStyle(Enum): 8 | """Node representation styles for ASCII rendering. 9 | 10 | Attributes 11 | ---------- 12 | MINIMAL : str 13 | No decorators, just the node label 14 | SQUARE : str 15 | Node label in square brackets [node] 16 | ROUND : str 17 | Node label in parentheses (node) 18 | DIAMOND : str 19 | Node label in angle brackets 20 | """ 21 | 22 | MINIMAL = "minimal" 23 | SQUARE = "square" 24 | ROUND = "round" 25 | DIAMOND = "diamond" 26 | CUSTOM = "custom" 27 | 28 | 29 | class EdgeChar: 30 | """ 31 | Descriptor for ASCII/Unicode character pairs. 32 | 33 | Provides automatic fallback to ASCII characters when needed. 34 | """ 35 | 36 | def __init__(self, ascii_char: str, unicode_char: str) -> None: 37 | self.ascii_char = ascii_char 38 | self.unicode_char = unicode_char 39 | 40 | def __get__(self, obj: Optional[Any], objtype: Optional[type] = None) -> str: 41 | """Get the appropriate character. 42 | 43 | Returns str when accessed on instance, EdgeChar when accessed on class. 44 | """ 45 | if obj is None: 46 | return self # type: ignore[return-value] 47 | return self.ascii_char if obj.use_ascii else self.unicode_char 48 | 49 | def __set__(self, obj: Any, value: str) -> None: 50 | self.unicode_char = value 51 | 52 | 53 | # In styles.py 54 | 55 | 56 | @dataclass 57 | class LayoutOptions: 58 | """Configuration options for graph layout and appearance. 59 | 60 | Core Spacing Parameters: 61 | node_spacing: Horizontal space between nodes (minimum 1) 62 | layer_spacing: Vertical space between layers (minimum 0) 63 | margin: General margin around the entire diagram 64 | 65 | Layout Control: 66 | node_style: Style for node representation (square, round, etc.) 67 | show_arrows: Whether to show direction arrows on edges 68 | use_ascii: Force ASCII output instead of Unicode 69 | 70 | Advanced Layout Control: 71 | left_padding: Extra space on left side of diagram (default 4) 72 | right_padding: Extra space on right side of diagram (default 4) 73 | min_edge_space: Minimum space needed between nodes for edge drawing (default 2) 74 | preserve_triangle_shape: Keep triangular layouts proportional (default True) 75 | triangle_height_ratio: Height to width ratio for triangles (default 0.866) 76 | """ 77 | 78 | _instance_counter = 0 # Class variable for counting instances 79 | 80 | # Core parameters (existing) 81 | node_spacing: int = field(default=4) 82 | margin: int = field(default=1) 83 | layer_spacing: int = field(default=2) 84 | node_style: Union[NodeStyle, str] = NodeStyle.SQUARE 85 | show_arrows: bool = True 86 | use_ascii: Optional[bool] = None 87 | custom_decorators: Optional[Dict[str, Tuple[str, str]]] = field( 88 | default_factory=dict 89 | ) 90 | 91 | # New layout control parameters 92 | left_padding: int = field(default=4) 93 | right_padding: int = field(default=4) 94 | min_edge_space: int = field(default=2) 95 | preserve_triangle_shape: bool = field(default=True) 96 | triangle_height_ratio: float = field(default=0.866) # sqrt(3)/2 for equilateral 97 | 98 | # Instance-specific ID (unchanged) 99 | instance_id: int = field(init=False) 100 | 101 | # Edge characters with ASCII fallbacks 102 | edge_cross = EdgeChar( 103 | "+", "+" 104 | ) # '┼' seems unnecessarily large, and will be replaced by proper corner chars soon 105 | edge_vertical = EdgeChar("|", "│") 106 | edge_horizontal = EdgeChar("-", "─") 107 | edge_arrow_r = EdgeChar(">", "→") 108 | edge_arrow_l = EdgeChar("<", "←") 109 | edge_arrow_up = EdgeChar("^", "↑") 110 | edge_arrow_down = EdgeChar("v", "↓") 111 | 112 | def __post_init__(self) -> None: 113 | """Validate and normalize configuration values.""" 114 | self.instance_id = LayoutOptions._instance_counter 115 | LayoutOptions._instance_counter += 1 116 | 117 | if isinstance(self.node_style, str): 118 | try: 119 | self.node_style = NodeStyle[self.node_style.upper()] 120 | except KeyError: 121 | valid_styles = ", ".join([style.name.lower() for style in NodeStyle]) 122 | raise ValueError( 123 | f"Invalid node style '{self.node_style}'. Valid options are: {valid_styles}" 124 | ) 125 | 126 | if self.node_style == NodeStyle.CUSTOM and not self.custom_decorators: 127 | raise ValueError( 128 | "Custom decorators must be provided when using NodeStyle.CUSTOM" 129 | ) 130 | 131 | # Validate core spacing parameters 132 | if self.node_spacing < 1: 133 | raise ValueError("node_spacing must be at least 1") 134 | if self.layer_spacing < 0: 135 | raise ValueError("layer_spacing must be non-negative") 136 | if self.margin < 1: 137 | raise ValueError("margin must be >= 1") 138 | 139 | # Validate new parameters 140 | if self.left_padding < 0: 141 | raise ValueError("left_padding must be non-negative") 142 | if self.right_padding < 0: 143 | raise ValueError("right_padding must be non-negative") 144 | if self.min_edge_space < 1: 145 | raise ValueError("min_edge_space must be at least 1") 146 | if self.triangle_height_ratio <= 0: 147 | raise ValueError("triangle_height_ratio must be positive") 148 | 149 | def get_effective_node_spacing(self, has_edges: bool = True) -> int: 150 | """Calculate effective node spacing considering edge requirements. 151 | 152 | Args: 153 | has_edges: Whether the nodes being spaced have edges between them 154 | 155 | Returns: 156 | Effective spacing to use between nodes 157 | """ 158 | if not has_edges: 159 | return self.node_spacing 160 | return max(self.node_spacing, self.min_edge_space) 161 | 162 | def get_paddings(self) -> Tuple[int, int]: 163 | """Get left and right padding values. 164 | 165 | Returns: 166 | Tuple of (left_padding, right_padding) 167 | """ 168 | return self.left_padding, self.right_padding 169 | 170 | def __str__(self) -> str: 171 | # Get all dataclass fields and their current values from this instance 172 | return f"""LayoutOptions: {', '.join( 173 | f"{field.name}={getattr(self, field.name)}" 174 | for field in fields(self) 175 | )}""" 176 | 177 | def get_node_decorators(self, node_str: str) -> Tuple[str, str]: 178 | """ 179 | Retrieve decorators for a specific node. 180 | 181 | Parameters 182 | ---------- 183 | node_str : str 184 | The string representation of the node. 185 | 186 | Returns 187 | ------- 188 | Tuple[str, str] 189 | A tuple containing the prefix and suffix for the node. 190 | """ 191 | # print(f"DBG TRACE: Entering get_node_decorators for node '{node_str}'") 192 | 193 | # print(f"DBG TRACE: Type self = {type(self)}") 194 | 195 | current_style = ( 196 | self.node_style.node_style 197 | if isinstance(self.node_style, LayoutOptions) 198 | else self.node_style 199 | ) 200 | 201 | # print(f"DBG TRACE: After extraction, current_style = {current_style}") 202 | # print(f"DBG TRACE: Type of current_style = {type(current_style)}") 203 | 204 | # Now check if we're in custom mode 205 | if current_style == NodeStyle.CUSTOM: 206 | if not self.custom_decorators: 207 | raise ValueError( 208 | "Custom decorators must be provided when using NodeStyle.CUSTOM" 209 | ) 210 | return self.custom_decorators.get(node_str, ("*", "*")) 211 | 212 | # For standard styles, use pattern matching 213 | match current_style: 214 | case NodeStyle.MINIMAL: 215 | return "", "" 216 | case NodeStyle.SQUARE: 217 | return "[", "]" 218 | case NodeStyle.ROUND: 219 | return "(", ")" 220 | case NodeStyle.DIAMOND: 221 | return "<", ">" 222 | case _: 223 | return "[", "]" # Default to square brackets 224 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottvr/phart/024960c93a2b3eb968f9d8ffe7966263defed6ae/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Tests for PHART CLI functionality.""" 2 | # src path: tests\test_cli.py 3 | 4 | import unittest 5 | import tempfile 6 | from pathlib import Path 7 | import sys 8 | from io import StringIO 9 | 10 | from phart.cli import main 11 | 12 | 13 | class TestCLI(unittest.TestCase): 14 | def setUp(self): 15 | """Set up test environment.""" 16 | self.temp_dir = tempfile.mkdtemp() 17 | self.test_text_file = Path(self.temp_dir) / "test.txt" 18 | 19 | # Create a valid DOT file 20 | self.dot_content = """ 21 | digraph { 22 | A -> B; 23 | B -> C; 24 | } 25 | """ 26 | self.test_text_file.write_text(self.dot_content, encoding="utf-8") 27 | # Create test Python file with main() function 28 | self.py_main_file = Path(self.temp_dir) / "test_main.py" 29 | main_content = """ 30 | import networkx as nx 31 | from phart import ASCIIRenderer 32 | 33 | def main(): 34 | G = nx.DiGraph() 35 | G.add_edges_from([("A", "B"), ("B", "C")]) 36 | renderer = ASCIIRenderer(G) 37 | print(renderer.render()) 38 | """ 39 | self.py_main_file.write_text(main_content, encoding="utf-8") 40 | 41 | # Create test Python file with __main__ block 42 | self.py_block_file = Path(self.temp_dir) / "test_block.py" 43 | block_content = """ 44 | import networkx as nx 45 | from phart import ASCIIRenderer 46 | 47 | if __name__ == "__main__": 48 | G = nx.DiGraph() 49 | G.add_edges_from([("X", "Y"), ("Y", "Z")]) 50 | renderer = ASCIIRenderer(G) 51 | print(renderer.render()) 52 | """ 53 | self.py_block_file.write_text(block_content, encoding="utf-8") 54 | 55 | # Create test Python file with custom function 56 | self.py_custom_file = Path(self.temp_dir) / "test_custom.py" 57 | custom_content = """ 58 | import networkx as nx 59 | from phart import ASCIIRenderer 60 | 61 | def demonstrate_graph(): 62 | G = nx.DiGraph() 63 | G.add_edges_from([("P", "Q"), ("Q", "R")]) 64 | renderer = ASCIIRenderer(G) 65 | print(renderer.render()) 66 | """ 67 | self.py_custom_file.write_text(custom_content, encoding="utf-8") 68 | 69 | # Save original stdout/stderr 70 | self.old_stdout = sys.stdout 71 | self.old_stderr = sys.stderr 72 | self.stdout = StringIO() 73 | self.stderr = StringIO() 74 | sys.stdout = self.stdout 75 | sys.stderr = self.stderr 76 | 77 | # Save original argv 78 | self.old_argv = sys.argv 79 | 80 | def tearDown(self): 81 | """Restore environment.""" 82 | sys.stdout = self.old_stdout 83 | sys.stderr = self.old_stderr 84 | sys.argv = self.old_argv 85 | self.test_text_file.unlink() 86 | Path(self.temp_dir).rmdir() 87 | 88 | def test_basic_rendering(self): 89 | """Test basic DOT file rendering.""" 90 | sys.argv = ["phart", str(self.test_text_file)] 91 | exit_code = main() 92 | self.assertEqual(exit_code, 0) 93 | output = self.stdout.getvalue() 94 | print(output) 95 | self.assertIn("A", output) 96 | self.assertIn("B", output) 97 | self.assertIn("C", output) 98 | self.assertEqual(self.stderr.getvalue(), "") # No errors 99 | 100 | def test_style_option(self): 101 | """Test node style option.""" 102 | sys.argv = ["phart", "--style", "round", str(self.test_text_file)] 103 | exit_code = main() 104 | self.assertEqual(exit_code, 0) 105 | output = self.stdout.getvalue() 106 | self.assertIn("(A)", output) 107 | self.assertIn("(B)", output) 108 | self.assertEqual(self.stderr.getvalue(), "") 109 | 110 | def test_charset_unicode(self): 111 | """Test explicit unicode charset option.""" 112 | sys.argv = ["phart", "--charset", "unicode", str(self.test_text_file)] 113 | exit_code = main() 114 | self.assertEqual(exit_code, 0) 115 | output = self.stdout.getvalue() 116 | # Should find at least one unicode character 117 | self.assertTrue(any(ord(c) > 127 for c in output)) 118 | self.assertEqual(self.stderr.getvalue(), "") 119 | 120 | def test_charset_ascii(self): 121 | """Test ASCII charset option.""" 122 | sys.argv = ["phart", "--charset", "ascii", str(self.test_text_file)] 123 | exit_code = main() 124 | self.assertEqual(exit_code, 0) 125 | output = self.stdout.getvalue() 126 | # All characters should be ASCII 127 | self.assertTrue(all(ord(c) < 128 for c in output)) 128 | self.assertEqual(self.stderr.getvalue(), "") 129 | 130 | def test_legacy_ascii_flag(self): 131 | """Test that legacy --ascii flag still works.""" 132 | sys.argv = ["phart", "--ascii", str(self.test_text_file)] 133 | exit_code = main() 134 | self.assertEqual(exit_code, 0) 135 | output = self.stdout.getvalue() 136 | # All characters should be ASCII 137 | self.assertTrue(all(ord(c) < 128 for c in output)) 138 | self.assertEqual(self.stderr.getvalue(), "") 139 | 140 | def test_charset_and_legacy_flag(self): 141 | """Test interaction between --charset and --ascii flags.""" 142 | # When both specified, --ascii should override --charset unicode 143 | sys.argv = [ 144 | "phart", 145 | "--charset", 146 | "unicode", 147 | "--ascii", 148 | str(self.test_text_file), 149 | ] 150 | exit_code = main() 151 | self.assertEqual(exit_code, 0) 152 | output = self.stdout.getvalue() 153 | # Should still be ASCII-only despite unicode charset 154 | self.assertTrue(all(ord(c) < 128 for c in output)) 155 | self.assertEqual(self.stderr.getvalue(), "") 156 | 157 | def test_invalid_file(self): 158 | """Test handling of invalid file.""" 159 | sys.argv = ["phart", "nonexistent.dot"] 160 | exit_code = main() 161 | self.assertEqual(exit_code, 1) 162 | self.assertIn("Error", self.stderr.getvalue()) 163 | 164 | def test_invalid_content(self): 165 | """Test handling of invalid input content.""" 166 | self.test_text_file.write_text("This is not a valid graph format") 167 | sys.argv = ["phart", str(self.test_text_file)] 168 | exit_code = main() 169 | self.assertEqual(exit_code, 1) 170 | error_msg = self.stderr.getvalue() 171 | self.assertIn("Error", error_msg) 172 | self.assertIn("Could not parse file as GraphML or DOT format", error_msg) 173 | 174 | def test_python_with_main(self): 175 | """Test executing Python file with main() function.""" 176 | sys.argv = ["phart", str(self.py_main_file)] 177 | exit_code = main() 178 | self.assertEqual(exit_code, 0) 179 | output = self.stdout.getvalue() 180 | self.assertIn("A", output) 181 | self.assertIn("B", output) 182 | self.assertIn("C", output) 183 | self.assertEqual(self.stderr.getvalue(), "") 184 | 185 | def test_python_with_main_block(self): 186 | """Test executing Python file with __main__ block.""" 187 | sys.argv = ["phart", str(self.py_block_file)] 188 | exit_code = main() 189 | self.assertEqual(exit_code, 0) 190 | output = self.stdout.getvalue() 191 | self.assertIn("X", output) 192 | self.assertIn("Y", output) 193 | self.assertIn("Z", output) 194 | self.assertEqual(self.stderr.getvalue(), "") 195 | 196 | def test_python_custom_function(self): 197 | """Test executing Python file with custom function.""" 198 | sys.argv = [ 199 | "phart", 200 | str(self.py_custom_file), 201 | "--function", 202 | "demonstrate_graph", 203 | ] 204 | exit_code = main() 205 | self.assertEqual(exit_code, 0) 206 | output = self.stdout.getvalue() 207 | self.assertIn("P", output) 208 | self.assertIn("Q", output) 209 | self.assertIn("R", output) 210 | self.assertEqual(self.stderr.getvalue(), "") 211 | 212 | def test_python_missing_function(self): 213 | """Test error handling for missing function.""" 214 | sys.argv = ["phart", str(self.py_custom_file), "--function", "nonexistent"] 215 | exit_code = main() 216 | self.assertEqual(exit_code, 1) 217 | self.assertIn("Error: Function 'nonexistent' not found", self.stderr.getvalue()) 218 | -------------------------------------------------------------------------------- /tests/test_performance.py: -------------------------------------------------------------------------------- 1 | """Performance tests for PHART ASCII renderer.""" 2 | # src path: tests\test_performance.py 3 | 4 | import random 5 | import time 6 | import unittest 7 | 8 | import networkx as nx # type: ignore 9 | 10 | from phart import ASCIIRenderer, NodeStyle 11 | 12 | 13 | def create_binary_tree(depth: int) -> nx.DiGraph: 14 | """Create a binary tree of specified depth.""" 15 | G = nx.DiGraph() 16 | 17 | def add_nodes(parent: int, d: int): 18 | if d >= depth: 19 | return 20 | left, right = 2 * parent + 1, 2 * parent + 2 21 | G.add_edges_from([(f"N{parent}", f"N{left}"), (f"N{parent}", f"N{right}")]) 22 | add_nodes(left, d + 1) 23 | add_nodes(right, d + 1) 24 | 25 | add_nodes(0, 0) 26 | return G 27 | 28 | 29 | def create_random_dag(n: int, p: float) -> nx.DiGraph: 30 | """Create random DAG with n nodes and edge probability p.""" 31 | # Create base graph 32 | G = nx.DiGraph() 33 | nodes = range(n) 34 | G.add_nodes_from(nodes) 35 | 36 | # Add edges ensuring acyclicity by only connecting to higher-numbered nodes 37 | for i in nodes: 38 | for j in range(i + 1, n): 39 | if random.random() < p: 40 | G.add_edge(i, j) 41 | 42 | return G 43 | 44 | 45 | def create_dependency_graph(n_layers: int, width: int) -> nx.DiGraph: 46 | """Create layered dependency graph.""" 47 | G = nx.DiGraph() 48 | prev_layer = [f"L0_{i}" for i in range(width)] 49 | G.add_nodes_from(prev_layer) 50 | 51 | for layer in range(1, n_layers): 52 | curr_layer = [f"L{layer}_{i}" for i in range(width)] 53 | G.add_nodes_from(curr_layer) 54 | for node in curr_layer: 55 | deps = random.sample(prev_layer, random.randint(1, min(3, len(prev_layer)))) 56 | G.add_edges_from((dep, node) for dep in deps) 57 | prev_layer = curr_layer 58 | return G 59 | 60 | 61 | class TestPerformance(unittest.TestCase): 62 | """Performance tests for the ASCII renderer.""" 63 | 64 | def setUp(self): 65 | """Set up performance test parameters.""" 66 | self.styles = list(NodeStyle) 67 | self.results = {} 68 | 69 | def test_binary_tree_scaling(self): 70 | """Test performance scaling with binary tree depth.""" 71 | depths = [3, 5, 7, 9] # 2^depth - 1 nodes 72 | for depth in depths: 73 | G = create_binary_tree(depth) 74 | for style in self.styles: 75 | if style.name not in "CUSTOM": 76 | renderer = ASCIIRenderer(G, node_style=style) 77 | 78 | start_time = time.perf_counter() 79 | result = renderer.render() 80 | elapsed = time.perf_counter() - start_time 81 | 82 | key = f"binary_tree_d{depth}_{style.name}" 83 | self.results[key] = { 84 | "time": elapsed, 85 | "nodes": G.number_of_nodes(), 86 | "edges": G.number_of_edges(), 87 | "output_size": len(result), 88 | } 89 | 90 | def test_random_dag_scaling(self): 91 | """Test performance with random DAGs of increasing size.""" 92 | sizes = [10, 50, 100, 200] 93 | edge_probability = 0.1 94 | 95 | for n in sizes: 96 | G = create_random_dag(n, edge_probability) 97 | renderer = ASCIIRenderer(G) 98 | 99 | start_time = time.perf_counter() 100 | result = renderer.render() 101 | elapsed = time.perf_counter() - start_time 102 | 103 | key = f"random_dag_n{n}" 104 | self.results[key] = { 105 | "time": elapsed, 106 | "nodes": G.number_of_nodes(), 107 | "edges": G.number_of_edges(), 108 | "output_size": len(result), 109 | } 110 | 111 | def test_dependency_graph_scaling(self): 112 | """Test performance with layered dependency graphs.""" 113 | configs = [ 114 | (3, 5), # 3 layers, 5 nodes per layer 115 | (5, 7), # 5 layers, 7 nodes per layer 116 | (7, 10), # 7 layers, 10 nodes per layer 117 | (10, 15), # 10 layers, 15 nodes per layer 118 | ] 119 | 120 | for n_layers, width in configs: 121 | G = create_dependency_graph(n_layers, width) 122 | renderer = ASCIIRenderer(G) 123 | 124 | start_time = time.perf_counter() 125 | result = renderer.render() 126 | elapsed = time.perf_counter() - start_time 127 | 128 | key = f"dep_graph_l{n_layers}w{width}" 129 | self.results[key] = { 130 | "time": elapsed, 131 | "nodes": G.number_of_nodes(), 132 | "edges": G.number_of_edges(), 133 | "output_size": len(result), 134 | } 135 | 136 | def tearDown(self): 137 | """Print performance results.""" 138 | print("\nPerformance Results:") 139 | print("=" * 80) 140 | for test, data in sorted(self.results.items()): 141 | print(f"\n{test}:") 142 | print(f" Time: {data['time']:.4f} seconds") 143 | print(f" Nodes: {data['nodes']}") 144 | print(f" Edges: {data['edges']}") 145 | print(f" Output Size: {data['output_size']} characters") 146 | 147 | 148 | if __name__ == "__main__": 149 | unittest.main(verbosity=2) 150 | -------------------------------------------------------------------------------- /tests/test_renderer.py: -------------------------------------------------------------------------------- 1 | """Test suite for PHART ASCII graph renderer.""" 2 | # src path: tests\test_renderer.py 3 | 4 | import unittest 5 | import sys 6 | 7 | import networkx as nx # type: ignore 8 | 9 | from phart import ASCIIRenderer, LayoutOptions, NodeStyle 10 | 11 | from pathlib import Path 12 | 13 | import tempfile 14 | 15 | 16 | class TestASCIIRenderer(unittest.TestCase): 17 | """Test cases for basic rendering functionality and encoding.""" 18 | 19 | def setUp(self): 20 | print(f"\nPython version: {sys.version}") 21 | print(f"NetworkX version: {nx.__version__}") 22 | 23 | # Try both construction methods to see difference 24 | try: 25 | print("\nTrying constructor with edge list:") 26 | self.chain = nx.DiGraph([("A", "B"), ("B", "C")]) 27 | except Exception as e: 28 | print(f"Constructor failed: {type(e).__name__}: {e}") 29 | 30 | try: 31 | print("\nTrying add_edges_from:") 32 | G = nx.DiGraph() 33 | G.add_edges_from([("A", "B"), ("B", "C")]) 34 | print("add_edges_from succeeded") 35 | except Exception as e: 36 | print(f"add_edges_from failed: {type(e).__name__}: {e}") 37 | 38 | """Set up common test graphs.""" 39 | # Existing test graph setup... 40 | self.chain = nx.DiGraph([("A", "B"), ("B", "C")]) 41 | self.tree = nx.DiGraph( 42 | [("A", "B"), ("A", "C"), ("B", "D"), ("B", "E"), ("C", "F")] 43 | ) 44 | 45 | # Diamond pattern (convergent paths) 46 | self.diamond = nx.DiGraph([("A", "B"), ("A", "C"), ("B", "D"), ("C", "D")]) 47 | 48 | # Graph with cycle 49 | self.cycle = nx.DiGraph([("A", "B"), ("B", "C"), ("C", "A")]) 50 | 51 | # Disconnected components 52 | self.disconnected = nx.DiGraph([("A", "B"), ("C", "D")]) 53 | 54 | # Complex graph 55 | self.complex = nx.DiGraph( 56 | [ 57 | ("A", "B"), 58 | ("A", "C"), 59 | ("B", "D"), 60 | ("C", "D"), 61 | ("D", "E"), 62 | ("B", "C"), 63 | ("E", "F"), 64 | ("F", "D"), # Creates cycle 65 | ] 66 | ) 67 | 68 | def test_basic_chain(self): 69 | """Test rendering of a simple chain graph.""" 70 | renderer = ASCIIRenderer(self.chain, layer_spacing=3) 71 | result = renderer.render(print_config=True) 72 | lines = result.split("\n") 73 | 74 | for line in lines: 75 | print(f"DBG: xxx: line={line}") 76 | # Verify nodes appear in correct order 77 | self.assertTrue(any("A" in line and "B" not in line for line in lines)) 78 | self.assertTrue( 79 | any("B" in line and "A" not in line and "C" not in line for line in lines) 80 | ) 81 | self.assertTrue(any("C" in line and "B" not in line for line in lines)) 82 | 83 | # Verify edge characters 84 | print("checking for pipes") 85 | for line in lines: 86 | print(f"{line}\n") 87 | self.assertTrue(any("|" in line or "│" in line for line in lines)) 88 | 89 | def test_tree_structure(self): 90 | """Test rendering of a tree structure.""" 91 | renderer = ASCIIRenderer(self.tree) 92 | result = renderer.render() 93 | lines = result.split("\n") 94 | 95 | # Root should be at top 96 | self.assertTrue( 97 | any("A" in line and not any(c in line for c in "BCDEF") for line in lines) 98 | ) 99 | 100 | # Verify branching structure 101 | b_line = next(i for i, line in enumerate(lines) if "B" in line) 102 | c_line = next(i for i, line in enumerate(lines) if "C" in line) 103 | self.assertEqual(b_line, c_line) # B and C should be on same level 104 | 105 | # Verify leaves 106 | d_line = next(i for i, line in enumerate(lines) if "D" in line) 107 | e_line = next(i for i, line in enumerate(lines) if "E" in line) 108 | f_line = next(i for i, line in enumerate(lines) if "F" in line) 109 | self.assertEqual(d_line, e_line) # D and E should be on same level 110 | self.assertEqual(e_line, f_line) # E and F should be on same level 111 | 112 | def test_node_styles(self): 113 | """Test different node style options.""" 114 | for style in NodeStyle: 115 | if style.name not in "CUSTOM": 116 | renderer = ASCIIRenderer( 117 | self.chain, options=LayoutOptions(node_style=style) 118 | ) 119 | result = renderer.render() 120 | 121 | if style == NodeStyle.SQUARE: 122 | self.assertIn("[A]", result) 123 | self.assertIn("[B]", result) 124 | elif style == NodeStyle.ROUND: 125 | self.assertIn("(A)", result) 126 | self.assertIn("(B)", result) 127 | elif style == NodeStyle.DIAMOND: 128 | self.assertIn("", result) 129 | self.assertIn("", result) 130 | else: # MINIMAL 131 | self.assertIn("A", result) 132 | self.assertIn("B", result) 133 | 134 | def test_cycle_handling(self): 135 | """Test proper handling of cycles in graphs.""" 136 | renderer = ASCIIRenderer(self.cycle) 137 | result = renderer.render() 138 | lines = result.split("\n") 139 | 140 | # Verify all nodes are present 141 | for node in "ABC": 142 | self.assertTrue(any(node in line for line in lines)) 143 | 144 | # Nodes shouldn't all be on same line 145 | nodes_per_line = [sum(1 for n in "ABC" if n in line) for line in lines] 146 | self.assertTrue(max(nodes_per_line) < 3) 147 | 148 | def test_disconnected_components(self): 149 | """Test handling of disconnected components.""" 150 | # Create a graph with two clearly disconnected components 151 | self.disconnected = nx.DiGraph( 152 | [ 153 | ("A", "B"), # Component 1 154 | ("C", "D"), # Component 2 155 | ] 156 | ) 157 | renderer = ASCIIRenderer(self.disconnected) 158 | result = renderer.render() 159 | lines = result.split("\n") 160 | 161 | # Find all lines containing each component 162 | comp1_lines = set( 163 | i for i, line in enumerate(lines) if any(node in line for node in "AB") 164 | ) 165 | comp2_lines = set( 166 | i for i, line in enumerate(lines) if any(node in line for node in "CD") 167 | ) 168 | 169 | # Components should not share any lines 170 | self.assertTrue( 171 | not comp1_lines & comp2_lines, 172 | "Disconnected components should be rendered on different lines", 173 | ) 174 | # Components should have some vertical separation 175 | min1, max1 = min(comp1_lines), max(comp1_lines) 176 | min2, max2 = min(comp2_lines), max(comp2_lines) 177 | self.assertTrue( 178 | max1 < min2 or max2 < min1, "Components should be vertically separated" 179 | ) 180 | 181 | def test_empty_graph(self): 182 | """Test handling of empty graph.""" 183 | empty = nx.DiGraph() 184 | renderer = ASCIIRenderer(empty) 185 | result = renderer.render() 186 | self.assertEqual(result.strip(), "") 187 | 188 | def test_single_node(self): 189 | """Test rendering of single-node graph.""" 190 | single = nx.DiGraph() 191 | single.add_node("A") 192 | renderer = ASCIIRenderer(single) 193 | result = renderer.render() 194 | self.assertEqual(result.strip(), "[A]") 195 | 196 | def test_from_dot(self): 197 | """Test creation from DOT format.""" 198 | dot_string = """ 199 | digraph { 200 | A -> B; 201 | B -> C; 202 | } 203 | """ 204 | renderer = ASCIIRenderer.from_dot(dot_string) 205 | result = renderer.render() 206 | 207 | # Verify basic structure 208 | self.assertIn("A", result) 209 | self.assertIn("B", result) 210 | self.assertIn("C", result) 211 | 212 | def test_auto_ascii_detection(self): 213 | """Test that ASCII mode is auto-detected correctly.""" 214 | renderer = ASCIIRenderer(self.chain) 215 | self.assertEqual(renderer.options.use_ascii, not renderer._can_use_unicode()) 216 | 217 | def test_force_ascii_mode(self): 218 | """Test forcing ASCII mode.""" 219 | renderer = ASCIIRenderer(self.chain, use_ascii=True) 220 | result = renderer.render() 221 | self.assertTrue(all(ord(c) < 128 for c in result)) 222 | 223 | def test_unicode_mode(self): 224 | """Test Unicode mode.""" 225 | renderer = ASCIIRenderer(self.chain, use_ascii=False) 226 | result = renderer.render() 227 | self.assertTrue(any(ord(c) > 127 for c in result)) 228 | 229 | def test_file_writing(self): 230 | """Test writing to file with proper encoding.""" 231 | 232 | renderer = ASCIIRenderer(self.chain) 233 | with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", delete=False) as f: 234 | name = f.name 235 | try: 236 | renderer.write_to_file(name) 237 | with open(name, "r", encoding="utf-8") as f2: 238 | content = f2.read() 239 | self.assertEqual(content, renderer.render()) 240 | finally: 241 | Path(name).unlink() # Clean up temp file 242 | 243 | def test_graphml_import(self): 244 | """Test creating renderer from GraphML file.""" 245 | G = nx.DiGraph([("A", "B"), ("B", "C")]) 246 | temp_dir = tempfile.mkdtemp() 247 | try: 248 | temp_file = Path(temp_dir) / "test.graphml" 249 | nx.write_graphml(G, str(temp_file)) 250 | renderer = ASCIIRenderer.from_graphml(str(temp_file)) 251 | result = renderer.render() 252 | self.assertIn("A", result) 253 | self.assertIn("B", result) 254 | self.assertIn("C", result) 255 | finally: 256 | if temp_file.exists(): 257 | temp_file.unlink() 258 | Path(temp_dir).rmdir() 259 | 260 | def test_invalid_graphml(self): 261 | """Test handling of invalid GraphML file.""" 262 | with tempfile.NamedTemporaryFile(suffix=".graphml") as f: 263 | f.write(b"not valid graphml") 264 | f.flush() 265 | with self.assertRaises(ValueError): 266 | ASCIIRenderer.from_graphml(f.name) 267 | 268 | 269 | class TestLayoutOptions(unittest.TestCase): 270 | def test_edge_chars_ascii_fallback(self): 271 | """Test that edge characters properly fall back to ASCII when needed.""" 272 | options = LayoutOptions(use_ascii=False) 273 | self.assertEqual(options.edge_vertical, "│") 274 | self.assertEqual(options.edge_horizontal, "─") 275 | 276 | options.use_ascii = True 277 | self.assertEqual(options.edge_vertical, "|") 278 | self.assertEqual(options.edge_horizontal, "-") 279 | 280 | def test_edge_chars_custom(self): 281 | """Test that edge characters can be customized.""" 282 | options = LayoutOptions() 283 | original = options.edge_vertical 284 | options.edge_vertical = "X" 285 | self.assertEqual(options.edge_vertical, "X") 286 | 287 | # Reset for other tests 288 | options.edge_vertical = original 289 | 290 | def test_invalid_spacing(self): 291 | """Test validation of spacing parameters.""" 292 | with self.assertRaises(ValueError): 293 | LayoutOptions(node_spacing=0) 294 | 295 | with self.assertRaises(ValueError): 296 | LayoutOptions(layer_spacing=-1) 297 | 298 | options = LayoutOptions() 299 | self.assertGreater(options.node_spacing, 0) 300 | self.assertGreater(options.layer_spacing, 0) 301 | 302 | 303 | if __name__ == "__main__": 304 | unittest.main() 305 | -------------------------------------------------------------------------------- /tests/test_styles.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottvr/phart/024960c93a2b3eb968f9d8ffe7966263defed6ae/tests/test_styles.py -------------------------------------------------------------------------------- /tools/generate_requirements.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Generate requirements/*.txt files from pyproject.toml.""" 3 | # src path: tools\generate_requirements.py 4 | 5 | import sys 6 | from pathlib import Path 7 | 8 | try: # standard module since Python 3.11 9 | import tomllib as toml 10 | except ImportError: 11 | try: # available for older Python via pip 12 | import tomli as toml 13 | except ImportError: 14 | sys.exit("Please install `tomli` first: `pip install tomli`") 15 | 16 | script_pth = Path(__file__) 17 | repo_dir = script_pth.parent.parent 18 | script_relpth = script_pth.relative_to(repo_dir) 19 | header = [ 20 | f"# Generated via {script_relpth.as_posix()} and pre-commit hook.", 21 | "# Do not edit this file; modify pyproject.toml instead.", 22 | ] 23 | 24 | 25 | def generate_requirement_file(name: str, req_list: list[str]) -> None: 26 | req_fname = repo_dir / "requirements" / f"{name}.txt" 27 | req_fname.write_text("\n".join(header + req_list) + "\n") 28 | 29 | 30 | def main() -> None: 31 | pyproject = toml.loads((repo_dir / "pyproject.toml").read_text()) 32 | 33 | generate_requirement_file("default", pyproject["project"]["dependencies"]) 34 | 35 | for key, opt_list in pyproject["project"]["optional-dependencies"].items(): 36 | generate_requirement_file(key, opt_list) 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | --------------------------------------------------------------------------------