Skip to content

Commit a9ee9b0

Browse files
Merge pull request #10 from gambitproject/jupyter-bits
Simplify API
2 parents fb511ec + 25318b4 commit a9ee9b0

File tree

8 files changed

+1063
-1017
lines changed

8 files changed

+1063
-1017
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
strategy:
1313
fail-fast: false
1414
matrix:
15-
python-version: ["3.9", "3.13"]
15+
python-version: ["3.10", "3.13"]
1616

1717
steps:
1818
- uses: actions/checkout@v4

README.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
Game tree drawing tool for extensive form games that generates TikZ code, LaTeX documents, PDFs, and PNGs.
44

5+
Pass in an extensive form game file in `.ef` format with layout formatting, and `draw_tree` will generate a visual representation of the game tree.
6+
You can also pass in a file in `.efg` format, which will be converted to `.ef` internally, applying a default layout.
7+
58
## Installation
69

710
Clone the repo and install the package using pip:
@@ -14,7 +17,7 @@ pip install -e .
1417

1518
## Requirements
1619

17-
- Python 3.9+ (tested on 3.13)
20+
- Python 3.10+ (tested on 3.13)
1821
- LaTeX with TikZ (for PDF/PNG generation)
1922
- (optional) ImageMagick or Ghostscript or Poppler (for PNG generation)
2023

@@ -73,18 +76,11 @@ generate_png('games/example.ef', output_png='mygame.png', scale_factor=0.8) #
7376

7477
### Rendering in Jupyter Notebooks
7578

76-
First install the requirements, which include the `jupyter-tikz` extension:
77-
```bash
78-
pip install -r requirements.txt
79-
```
80-
8179
In a Jupyter notebook, run:
8280

8381
```python
84-
%load_ext jupyter_tikz
8582
from draw_tree import draw_tree
86-
example_tikz = draw_tree('games/example.ef')
87-
get_ipython().run_cell_magic("tikz", "", example_tikz)
83+
draw_tree('games/example.ef')
8884
```
8985

9086
## Developer docs: Testing

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ classifiers = [
2525
]
2626
keywords = ["game theory", "tikz", "visualization", "trees", "economics"]
2727

28+
# Required runtime dependencies (previously optional under the 'jupyter' extra)
29+
dependencies = ["jupyter-tikz", "ipykernel"]
30+
2831
[project.optional-dependencies]
29-
jupyter = ["jupyter-tikz", "ipykernel"]
3032
dev = ["pytest>=7.0.0", "pytest-cov"]
3133

3234
[project.scripts]

requirements.txt

Lines changed: 0 additions & 4 deletions
This file was deleted.

src/draw_tree/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .core import (
1111
draw_tree,
12+
generate_tikz,
1213
generate_tex,
1314
generate_pdf,
1415
generate_png,
@@ -19,6 +20,7 @@
1920

2021
__all__ = [
2122
"draw_tree",
23+
"generate_tikz",
2224
"generate_tex",
2325
"generate_pdf",
2426
"generate_png",

src/draw_tree/core.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from pathlib import Path
1717
from typing import List, Optional
18+
from IPython.core.getipython import get_ipython
1819

1920
# Constants
2021
DEFAULTFILE: str = "example.ef"
@@ -1250,7 +1251,7 @@ def ef_to_tex(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False)
12501251
scale = original_scale
12511252
grid = original_grid
12521253

1253-
def draw_tree(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False) -> str:
1254+
def generate_tikz(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False) -> str:
12541255
"""
12551256
Generate complete TikZ code from an extensive form (.ef) file.
12561257
@@ -1323,6 +1324,39 @@ def draw_tree(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False)
13231324
return tikz_code
13241325

13251326

1327+
def draw_tree(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False) -> Optional[str]:
1328+
"""
1329+
Generate TikZ code and display in Jupyter notebooks.
1330+
1331+
Args:
1332+
ef_file: Path to the .ef file to process.
1333+
scale_factor: Scale factor for the diagram (default: 1.0).
1334+
show_grid: Whether to show grid lines (default: False).
1335+
1336+
Returns:
1337+
The result of the Jupyter cell magic execution, or the TikZ code string
1338+
if cell magic fails.
1339+
"""
1340+
# Ensure we are in a Jupyter notebook environment
1341+
ip = get_ipython()
1342+
if ip:
1343+
# Only attempt to load the extension if it's not already loaded
1344+
em = getattr(ip, 'extension_manager', None)
1345+
loaded = getattr(em, 'loaded', None)
1346+
try:
1347+
jpt_loaded = 'jupyter_tikz' in loaded # type: ignore
1348+
except Exception:
1349+
jpt_loaded = False
1350+
if not jpt_loaded:
1351+
ip.run_line_magic("load_ext", "jupyter_tikz")
1352+
1353+
# Generate TikZ code and execute cell magic
1354+
tikz_code = generate_tikz(ef_file, scale_factor, show_grid)
1355+
return ip.run_cell_magic("tikz", "", tikz_code)
1356+
else:
1357+
raise EnvironmentError("draw_tree function requires a Jupyter notebook environment.")
1358+
1359+
13261360
def latex_wrapper(tikz_code: str) -> str:
13271361
"""
13281362
Wrap TikZ code in a complete LaTeX document.
@@ -1391,8 +1425,8 @@ def generate_tex(ef_file: str, output_tex: Optional[str] = None, scale_factor: f
13911425
except Exception:
13921426
pass
13931427

1394-
# Generate TikZ content using draw_tree
1395-
tikz_content = draw_tree(ef_file, scale_factor, show_grid)
1428+
# Generate TikZ content using generate_tikz
1429+
tikz_content = generate_tikz(ef_file, scale_factor, show_grid)
13961430

13971431
# Wrap in complete LaTeX document
13981432
latex_document = latex_wrapper(tikz_content)
@@ -1430,8 +1464,8 @@ def generate_pdf(ef_file: str, output_pdf: Optional[str] = None, scale_factor: f
14301464
ef_path = Path(ef_file)
14311465
output_pdf = ef_path.with_suffix('.pdf').name
14321466

1433-
# Generate TikZ content using draw_tree
1434-
tikz_content = draw_tree(ef_file, scale_factor, show_grid)
1467+
# Generate TikZ content using generate_tikz
1468+
tikz_content = generate_tikz(ef_file, scale_factor, show_grid)
14351469

14361470
# Create LaTeX wrapper document
14371471
latex_document = latex_wrapper(tikz_content)
@@ -2233,7 +2267,7 @@ def emit_node(n: 'DefaultLayout.Node'):
22332267

22342268

22352269
def efg_to_ef(efg_file: str) -> str:
2236-
"""Convert a Gambit .efg file to the `.ef` format used by draw_tree.
2270+
"""Convert a Gambit .efg file to the `.ef` format used by generate_tikz.
22372271
22382272
The function implements a focused parser and deterministic layout
22392273
heuristics for producing `.ef` directives from a conservative subset of

tests/test_drawtree.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ def test_draw_tree_basic(self):
263263
ef_file_path = ef_file.name
264264

265265
try:
266-
result = draw_tree.draw_tree(ef_file_path)
266+
result = draw_tree.generate_tikz(ef_file_path)
267267

268268
# Verify the result contains expected components
269269
assert isinstance(result, str)
@@ -282,6 +282,69 @@ def test_draw_tree_basic(self):
282282
finally:
283283
os.unlink(ef_file_path)
284284

285+
def test_draw_tree_raises_when_no_ipython(self):
286+
"""When IPython is not available, draw_tree should raise EnvironmentError."""
287+
with patch('draw_tree.core.get_ipython', return_value=None):
288+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.ef') as ef_file:
289+
ef_file.write("player 1\n")
290+
ef_file.write("level 0 node root player 1\n")
291+
ef_file_path = ef_file.name
292+
try:
293+
with pytest.raises(EnvironmentError):
294+
draw_tree.draw_tree(ef_file_path)
295+
finally:
296+
os.unlink(ef_file_path)
297+
298+
def test_draw_tree_calls_ipython_magic_when_available(self):
299+
"""When IPython is available, draw_tree should load the jupyter_tikz
300+
extension if needed and call the tikz cell magic with the generated code.
301+
"""
302+
# Create a simple .ef file for testing
303+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.ef') as ef_file:
304+
ef_file.write("player 1\n")
305+
ef_file.write("level 0 node root player 1\n")
306+
ef_file_path = ef_file.name
307+
308+
class DummyEM:
309+
def __init__(self, loaded=None):
310+
self.loaded = loaded or set()
311+
312+
class DummyIP:
313+
def __init__(self, em):
314+
self.extension_manager = em
315+
self._loaded_magics = []
316+
self._run_cell_magic_calls = []
317+
318+
def run_line_magic(self, name, arg):
319+
# record that load_ext was called
320+
self._loaded_magics.append((name, arg))
321+
322+
def run_cell_magic(self, magic_name, args, code):
323+
# record call and return a sentinel
324+
self._run_cell_magic_calls.append((magic_name, args, code))
325+
return "MAGIC-RESULT"
326+
327+
try:
328+
# Case 1: extension already loaded
329+
em = DummyEM(loaded={'jupyter_tikz'})
330+
ip = DummyIP(em)
331+
with patch('draw_tree.core.get_ipython', return_value=ip):
332+
res = draw_tree.draw_tree(ef_file_path)
333+
# Should call run_cell_magic and return its value
334+
assert res == "MAGIC-RESULT"
335+
336+
# Case 2: extension not loaded -> run_line_magic should be called
337+
em2 = DummyEM(loaded=set())
338+
ip2 = DummyIP(em2)
339+
with patch('draw_tree.core.get_ipython', return_value=ip2):
340+
res2 = draw_tree.draw_tree(ef_file_path)
341+
assert res2 == "MAGIC-RESULT"
342+
# run_line_magic should have been called to load the extension
343+
assert ('load_ext', 'jupyter_tikz') in ip2._loaded_magics
344+
345+
finally:
346+
os.unlink(ef_file_path)
347+
285348
def test_draw_tree_with_options(self):
286349
"""Test draw_tree with different options."""
287350
# Create a simple .ef file for testing
@@ -292,15 +355,15 @@ def test_draw_tree_with_options(self):
292355

293356
try:
294357
# Test with scale
295-
result_scaled = draw_tree.draw_tree(ef_file_path, scale_factor=2.0)
358+
result_scaled = draw_tree.generate_tikz(ef_file_path, scale_factor=2.0)
296359
assert "scale=2" in result_scaled
297360

298361
# Test with grid
299-
result_grid = draw_tree.draw_tree(ef_file_path, show_grid=True)
362+
result_grid = draw_tree.generate_tikz(ef_file_path, show_grid=True)
300363
assert "\\draw [help lines, color=green]" in result_grid
301364

302365
# Test without grid (default)
303-
result_no_grid = draw_tree.draw_tree(ef_file_path, show_grid=False)
366+
result_no_grid = draw_tree.generate_tikz(ef_file_path, show_grid=False)
304367
assert "% \\draw [help lines, color=green]" in result_no_grid
305368

306369
finally:
@@ -310,15 +373,15 @@ def test_draw_tree_missing_files(self):
310373
"""Test draw_tree with missing files."""
311374
# Test with missing .ef file
312375
with pytest.raises(FileNotFoundError):
313-
draw_tree.draw_tree("nonexistent.ef")
376+
draw_tree.generate_tikz("nonexistent.ef")
314377

315378
# Test with valid .ef file (should work with built-in macros)
316379
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.ef') as ef_file:
317380
ef_file.write("player 1\nlevel 0 node root player 1\n")
318381
ef_file_path = ef_file.name
319382

320383
try:
321-
result = draw_tree.draw_tree(ef_file_path)
384+
result = draw_tree.generate_tikz(ef_file_path)
322385
# Should work with built-in macros
323386
assert "\\begin{tikzpicture}" in result
324387
assert "\\newcommand\\chancecolor{red}" in result

0 commit comments

Comments
 (0)