├── .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 | 
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 | 
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 |
--------------------------------------------------------------------------------