Skip to content

Commit f9e6563

Browse files
committed
intern sphinx_exec_directive with HS backend
1 parent 315b5f7 commit f9e6563

File tree

7 files changed

+347
-6
lines changed

7 files changed

+347
-6
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ book
22
_build
33
*.el
44

5+
56
# profiling outputs
67
*.eventlog
78
*.eventlog.*
89
*.hp
910
*.prof
11+
*/__pycache__
1012

1113
# nix stuff
1214
.direnv/

.gitmodules

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "extensions/sphinx_exec_directive"]
2+
path = extensions/sphinx_exec_directive
3+
url = https://github.com/doyougnu/sphinx_exec_directive.git

conf.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
import os
1414
import sys
1515
import time
16-
sys.path.insert(0, os.path.abspath('.'))
16+
17+
sys.path.insert(0, os.path.abspath('extensions'))
1718

1819
# -- Project information -----------------------------------------------------
1920

@@ -42,7 +43,8 @@
4243
## underscore
4344
, 'sphinxcontrib.bibtex'
4445
, 'sphinx_copybutton'
45-
, 'sphinxcontrib.execHS.ext'
46+
# , 'sphinxcontrib.execHS.ext'
47+
, 'sphinx_exec_directive'
4648
]
4749

4850
# flags

extensions/sphinx_exec_directive

Submodule sphinx_exec_directive added at 8a93684

extensions/sphinx_exec_directive.py

+289
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import os
2+
import io
3+
import re
4+
import subprocess
5+
from hashlib import md5
6+
from contextlib import redirect_stdout
7+
from pathlib import Path
8+
from tempfile import NamedTemporaryFile
9+
10+
from docutils import nodes
11+
from docutils.parsers.rst import directives, Directive, Parser
12+
from docutils.utils import new_document
13+
14+
context = dict()
15+
previous_rst = None
16+
17+
18+
class cd:
19+
"""
20+
Context manager for changing the current working directory. Taken from
21+
https://stackoverflow.com/a/13197763/7115316.
22+
"""
23+
def __init__(self, newPath):
24+
self.newPath = os.path.expanduser(newPath)
25+
26+
def __enter__(self):
27+
self.savedPath = os.getcwd()
28+
os.chdir(self.newPath)
29+
30+
def __exit__(self, etype, value, traceback):
31+
os.chdir(self.savedPath)
32+
33+
34+
def execute_code(runner, globals_dict=None):
35+
36+
def execute_code_with_pipe(command, code_in, post_process=[]):
37+
proc = subprocess.Popen(command,
38+
stdin=subprocess.PIPE,
39+
stdout=subprocess.PIPE,
40+
stderr=subprocess.PIPE)
41+
out, err = proc.communicate(input=code_in.encode("utf-8"))
42+
43+
# apply all post processing functions now that we have output
44+
out = out.decode('utf-8')
45+
46+
for f in post_process:
47+
out = f(out)
48+
49+
# Log any stderr.
50+
if err is not None and err.strip() != "":
51+
print(err)
52+
53+
return out
54+
55+
if runner['process'] == 'python':
56+
if globals_dict is None:
57+
globals_dict = {}
58+
59+
output_object = io.StringIO()
60+
with redirect_stdout(output_object):
61+
exec(runner['code_in'], globals_dict)
62+
code_out = output_object.getvalue()
63+
64+
elif runner['process'] == 'haskell':
65+
post_process = []
66+
payload = []
67+
68+
# check that the runner with field is set
69+
# and set post-process hooks
70+
if not runner['with']:
71+
runner['with'] = 'runghc' # default is runghc, no hooks
72+
73+
if runner['with'] == 'ghci':
74+
# if running with ghci then we post process the output to remove
75+
# ghci specific text
76+
post_process += [lambda s: s.replace("ghci>",""),
77+
lambda s: re.sub("^.*?\n", "", s),
78+
lambda s: s.replace("Leaving GHCi.\n", "").rstrip()
79+
]
80+
81+
# do the business
82+
if runner['with'] == 'cabal' or runner['with'] == 'stack':
83+
if runner['project_dir']:
84+
with cd(Path(runner['project_dir'])):
85+
payload = [runner['with']] + runner['args']
86+
comp_proc = subprocess.run(payload, capture_output=True, text=True)
87+
out = comp_proc.stdout
88+
err = comp_proc.stderr
89+
# Log
90+
if err is not None and err.strip() != "":
91+
print(err) # should use sphinx logger
92+
code_out = out
93+
else:
94+
code_out = execute_code_with_pipe(runner['with'], runner['code_in'], post_process)
95+
96+
elif runner['process'] == 'matlab':
97+
# MATLAB can't pipe, so we need to dump to a tempfile.
98+
with NamedTemporaryFile(suffix=".m") as tempfile:
99+
tempfile.write(code.encode('utf-8'))
100+
tempfile.flush() # mandatory, or else it will be empty
101+
filepath = Path(tempfile.name)
102+
# Then execute MATLAB.
103+
with cd(filepath.parent):
104+
comp_proc = subprocess.run(['matlab', '-batch', filepath.stem],
105+
capture_output=True, text=True)
106+
out = comp_proc.stdout.decode('utf-8')
107+
err = comp_proc.stderr
108+
# Log any stderr.
109+
if err is not None and err.strip() != "":
110+
print(err)
111+
code_out = out
112+
113+
elif process == 'shell':
114+
code_out = execute_code_with_pipe(['sh'])
115+
116+
else:
117+
raise ValueError(f"process type '{process}' not recognised.")
118+
119+
return code_out
120+
121+
122+
def _option_boolean(arg):
123+
"""Copied from matplotlib plot_directive."""
124+
if not arg or not arg.strip():
125+
# no argument given, assume used as a flag
126+
return True
127+
elif arg.strip().lower() in ('no', '0', 'false'):
128+
return False
129+
elif arg.strip().lower() in ('yes', '1', 'true'):
130+
return True
131+
else:
132+
raise ValueError('"%s" unknown boolean' % arg)
133+
134+
135+
def _option_str(arg):
136+
return str(arg)
137+
138+
def _option_process(arg):
139+
if arg is None:
140+
return 'python'
141+
else:
142+
return arg.lower()
143+
144+
145+
class Exec(Directive):
146+
has_content = True
147+
required_arguments = 0
148+
optional_arguments = 1
149+
option_spec = {
150+
'context': _option_boolean,
151+
'cache': _option_boolean,
152+
'process': _option_process,
153+
'intertext': _option_str,
154+
'project_dir': _option_str,
155+
'with': _option_str,
156+
'args': _option_str
157+
}
158+
159+
def run(self):
160+
# Get the source file and if it has changed, then reset the context.
161+
current_rst = Path(self.state_machine.document.attributes['source'])
162+
global previous_rst
163+
if previous_rst is None or previous_rst != current_rst:
164+
previous_rst = current_rst
165+
context.clear()
166+
167+
# Parse options
168+
save_context = self.options.get('context', False)
169+
# Don't cache if the user requests saving context, or if the context is
170+
# nonempty. The reason is because the global_dict can't be updated just
171+
# by reading in code from a file (as opposed to executing it). I can't
172+
# be bothered to fix this (and truthfully I don't see an easy way,
173+
# short of serialising the entire contents of `context`).
174+
cache = (not save_context
175+
and len(context) == 0
176+
and self.options.get('cache', True))
177+
process = self.options.get('process', 'python')
178+
project_dir = self.options.get('project_dir', '')
179+
opt_with = self.options.get('with', '')
180+
args = self.options.get ('args','').split()
181+
182+
# A runner is "that which runs the code", i.e., a dictionary that
183+
# defines the entire external process
184+
runner = {'process': process, # the language
185+
'with': opt_with, # to run with what tool/binary
186+
'project_dir': project_dir, # if we're running from project
187+
# then store the project dir
188+
# 'code_in': '', # The code to
189+
# run, if from file then this is
190+
# the contents of source_file,
191+
# if not then its the contents
192+
# of a literal code block
193+
'args': args} # args to run with, with
194+
195+
# Determine whether input is to be read from a file, or directly from
196+
# the exec block's contents.
197+
from_file = len(self.arguments) > 0
198+
199+
# Get some important paths.
200+
# NOTE ABOUT PATHS:
201+
# Any variable ending in _pAD is an absolute path to a directory.
202+
# _pRD is a relative path to a directory.
203+
# _pAF is an absolute path to a file.
204+
# _pRF is a relative path to a file.
205+
top_level_sphinx_pAD = Path(setup.confdir)
206+
207+
# Determine where to get source code from. If we are using a build
208+
# system then the user file is actually a project directory
209+
if from_file:
210+
# Set the 'source file' to be the specified file. The argument to
211+
# the exec block is given as a relative path, so has to be made
212+
# absolute with respect to the top-level Sphinx directory.
213+
source_pAF = top_level_sphinx_pAD.joinpath(Path(self.arguments[0]))
214+
runner['code_in'] = source_pAF.read_text()
215+
runner['source_file'] = source_pAF
216+
else:
217+
# Set the 'source file' to be the rst file which the code is in.
218+
# This path is already absolute.
219+
source_pAF = current_rst
220+
runner['code_in'] = "\n".join(self.content)
221+
222+
# Look up the output in the cache, or execute the code.
223+
if cache:
224+
source_pRF = source_pAF.relative_to(top_level_sphinx_pAD)
225+
226+
# Figure out where to dump the output.
227+
if from_file:
228+
source_identifier = source_pRF.with_suffix('')
229+
source_identifier = str(source_identifier).replace('/', '-')
230+
identifier = f"{source_identifier}-{process}-file.out"
231+
# ^ folder-yymmdd-filename-python-file.out
232+
else:
233+
source_identifier = source_pRF.with_suffix('')
234+
source_identifier = str(source_identifier).replace('/', '-')
235+
md5_hash = md5(runner['code_in'].encode('utf-8')).hexdigest()
236+
identifier = (f"{source_identifier}-{process}-"
237+
f"inline-{md5_hash}.out")
238+
# ^ folder-yymmdd-python-inline-<HASH>.out
239+
build_pAD = Path(setup.app.doctreedir).parent
240+
output_pAF = build_pAD / "exec_directive" / identifier
241+
242+
# Look for the cached output. If not found, execute it.
243+
cache_found = (
244+
output_pAF.exists()
245+
and source_pAF.stat().st_mtime < output_pAF.stat().st_mtime
246+
)
247+
if cache_found:
248+
with open(output_pAF, "r") as out_f:
249+
code_out = out_f.read()
250+
else:
251+
code_out = execute_code(runner, context)
252+
if not output_pAF.parent.exists():
253+
output_pAF.parent.mkdir()
254+
with open(output_pAF, "w") as out_f:
255+
print(code_out, file=out_f, end="")
256+
else: # caching was disabled, execute it
257+
code_out = execute_code(runner, context)
258+
259+
# Reset the context if it's not meant to be preserved
260+
if not save_context:
261+
context.clear()
262+
263+
node_in = nodes.literal_block(runner['code_in'], runner['code_in'])
264+
node_out = nodes.literal_block(code_out, code_out)
265+
node_in['language'] = process
266+
node_out['language'] = 'none'
267+
268+
if code_out.strip() == "":
269+
return [node_in]
270+
else:
271+
intertext = self.options.get('intertext', None)
272+
if intertext:
273+
internodes = new_document('intertext', self.state.document.settings)
274+
Parser().parse(intertext, internodes)
275+
return [node_in, *internodes.document.children, node_out]
276+
else:
277+
return [node_in, node_out]
278+
279+
280+
def setup(app):
281+
setup.app = app
282+
setup.confdir = app.confdir
283+
app.add_directive("exec", Exec)
284+
285+
return {
286+
'version': '0.5',
287+
'parallel_read_safe': True,
288+
'parallel_write_safe': True,
289+
}

hoh.nix

+5-2
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,20 @@ let
1515
nonPythonInputs = with pkgs; [ sphinx-press-theme # this comes from the overlay
1616
sphinx-copybutton # this comes from the overlay
1717
pandoc
18-
sphinx-exec-directive
18+
# change once extension fixes are upstreamed
19+
# sphinx-exec-directive
1920
rst2html5
2021
sphinx-autobuild
2122
sphinx-exec-haskell
23+
ghc
24+
cabal-install
2225
];
2326
in
2427
pkgs.stdenv.mkDerivation {
2528
pname = "hoh";
2629
version = "0.0.1";
2730
src = ./.;
28-
buildInputs = pythonInputs ++ nonPythonInputs;
31+
propagatedBuildInputs = pythonInputs ++ nonPythonInputs;
2932

3033
preBuild = ''
3134
unset SOURCE_DATE_EPOCH

0 commit comments

Comments
 (0)