Skip to content

Commit

Permalink
- Use options from code-block
Browse files Browse the repository at this point in the history
- Dedent code and output
  • Loading branch information
spacemanspiff2007 committed Nov 15, 2024
1 parent 1c46b7c commit dc3af59
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 126 deletions.
53 changes: 50 additions & 3 deletions doc/description.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,21 @@ Generated view
Options
------------------------------
It's possible to further configure both the code block and the output block with the following options:
`See sphinx docs <https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code>`_
for a detailed description


hide_code/hide_output:
hide/hide_output:
Will hide the corresponding block
name
Define implicit target name that can be referenced by using ref. For example:
caption/caption_output
Will add a caption above the block
linenos/linenos_output
Will add line numbers
lineno-start/lineno-start_output
Set the first line number of the block. Linenos is also automatically activated
emphasize-lines/emphasize-lines_output
Emphasize particular lines of the block
language/language_output:
| Will add syntax highlighting for the specified language
| The default for the code block is python, the default for the output block is plain text
Expand All @@ -56,7 +63,7 @@ Generated view
:hide_output:
:caption: This is an important caption

print('Easy!')
print('Easy!')

----

Expand Down Expand Up @@ -171,3 +178,43 @@ Generated view
----

With the combination of ``skip`` and ``hide`` it's possible to "simulate" every code.


Further Examples
------------------------------

This is an example with captions, highlights and name

.. code-block:: python
.. exec_code::
:lineno-start: 5
:emphasize-lines: 1, 3
:caption: This is an important caption
:caption_output: This is an important output caption
:name: my_example_1
print('My')
# This is a comment
print('Output!')
Generated view

----

.. exec_code::
:lineno-start: 5
:emphasize-lines: 1, 3
:caption: This is an important caption
:caption_output: This is an important output caption
:name: my_example_1

print('My')
# This is a comment

print('Output!')

----

See :ref:`this code snippet <my_example_1>` for an example.
13 changes: 5 additions & 8 deletions src/sphinx_exec_code/code_format.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from textwrap import dedent
from typing import Iterable, List, Tuple

from docutils.statemachine import StringList


class VisibilityMarkerError(Exception):
pass
Expand Down Expand Up @@ -61,7 +64,7 @@ def get_lines(self) -> List[str]:
return code_lines


def get_show_exec_code(code_lines: Iterable[str]) -> Tuple[str, str]:
def get_show_exec_code(code_lines: StringList) -> Tuple[str, str]:
shown = CodeMarker('hide')
executed = CodeMarker('skip')

Expand All @@ -77,13 +80,7 @@ def get_show_exec_code(code_lines: Iterable[str]) -> Tuple[str, str]:

shown_lines = shown.get_lines()

# check if the shown code block is indented as a whole -> strip
leading_spaces = [len(line) - len(line.lstrip()) for line in shown_lines]
if strip_spaces := min(leading_spaces, default=0):
for i, line in enumerate(shown_lines):
shown_lines[i] = line[strip_spaces:]

shown_code = '\n'.join(shown_lines)
executed_code = '\n'.join(executed.get_lines())

return shown_code, executed_code.strip()
return dedent(shown_code), dedent(executed_code.strip())
57 changes: 34 additions & 23 deletions src/sphinx_exec_code/sphinx_exec.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import traceback
from pathlib import Path
from typing import List
from tokenize import String
from typing import List, Final, Tuple, Any

from docutils import nodes
from docutils.statemachine import StringList, StateMachine
from sphinx.directives.code import CodeBlock
from sphinx.errors import ExtensionError
from sphinx.util.docutils import SphinxDirective
from sphinx.util.docutils import SphinxDirective, LoggingReporter

from sphinx_exec_code.__const__ import log
from sphinx_exec_code.code_exec import CodeExceptionError, execute_code
Expand All @@ -13,24 +16,6 @@
from sphinx_exec_code.sphinx_spec import SphinxSpecBase, build_spec, get_specs


def create_literal_block(objs: list, code: str, spec: SphinxSpecBase) -> None:
if spec.hide or not code:
return None

# generate header if specified
if spec.caption:
objs.append(nodes.caption(text=spec.caption))

# generate code block
block = nodes.literal_block(code, code)
objs.append(block)

# set linenos
block['linenos'] = spec.linenos
block['language'] = spec.language
return None


class ExecCode(SphinxDirective):
""" Sphinx class for execute_code directive
"""
Expand All @@ -57,7 +42,7 @@ def run(self) -> list:
msg = f'Error while running {name}!'
raise ExtensionError(msg, orig_exc=e) from None

def _get_code_line(self, line_no: int, content: List[str]) -> int:
def _get_code_line(self, line_no: int, content: StringList) -> int:
"""Get the first line number of the code"""
if not content:
return line_no
Expand Down Expand Up @@ -99,7 +84,7 @@ def _run(self) -> list:
raise ExtensionError(msg, orig_exc=e) from None

# Show the code from the user
create_literal_block(output, code_show, spec=code_spec)
self.create_literal_block(output, code_show, code_spec, str(file), line)

try:
code_results = execute_code(code_exec, file, line)
Expand All @@ -115,5 +100,31 @@ def _run(self) -> list:
raise ExtensionError(msg) from None

# Show the output from the code execution
create_literal_block(output, code_results, spec=output_spec)
self.create_literal_block(output, code_results, output_spec, str(file), line)
return output

def create_literal_block(self, objs: list, code: str, spec: SphinxSpecBase, file: str, line: int) -> None:
if spec.hide or not code:
return None

c = CodeBlock(
'code-block', [spec.language], spec.spec,
StringList(code.splitlines()),
line,
# I'm not sure what these two do
self.content_offset, '',
# Make the state machine report the proper source file because we support loading from files
self.state, DummyStateMachine(file, self.state_machine.reporter)
)

objs.extend(c.run())
return None


class DummyStateMachine:
def __init__(self, source: str, reporter: Any) -> None:
self._source: Final = source
self.reporter: Final = reporter

def get_source_and_line(self, line: int) -> Tuple[str, int]:
return self._source, line
137 changes: 90 additions & 47 deletions src/sphinx_exec_code/sphinx_spec.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,80 @@
from typing import Any, Callable, ClassVar, Dict, Tuple
from typing import Any, ClassVar, Dict, Final, Tuple

from docutils.nodes import literal_block
from docutils.parsers.rst import directives # type: ignore
from sphinx.directives.code import CodeBlock
from sphinx.util.typing import OptionSpec

from sphinx_exec_code.__const__ import log


class SphinxSpecBase:
aliases: ClassVar[Dict[str, str]]
defaults: ClassVar[Dict[str, str]]
drop_code_block_option: ClassVar[Tuple[str, ...]]

@staticmethod
def _alias_to_name(alias: str) -> str:
raise NotImplementedError()

@staticmethod
def _name_to_alias(name: str) -> str:
raise NotImplementedError()

def __init__(self, spec: Dict[str, Any]) -> None:
self.hide: Final = spec.pop('hide')
self.language: Final = spec.pop('language')
self.spec: Final = spec

def __init__(self, hide: bool, linenos: bool, caption: str, language: str) -> None:
# flags
self.hide = hide
self.linenos = linenos
# values
self.caption = caption
self.language = language
def set_block_spec(self, block: literal_block) -> None:
for name, value in self.spec.items():
block[name] = value
return None

@classmethod
def from_options(cls, options: Dict[str, Any]) -> 'SphinxSpecBase':
opts = {}
for alias, name in cls.aliases.items():
if name not in cls.defaults:
# is a flag
opts[name] = alias in options
else:
# is a value
val = options.get(alias, None)
if not val:
val = cls.defaults[name]
opts[name] = val
spec_names = tuple(cls.create_spec().keys())

spec = {cls._alias_to_name(n): v for n, v in cls.defaults.items()}
for name in spec_names:
if name not in options:
continue
spec[cls._alias_to_name(name)] = options[name]

return cls(**opts)
return cls(spec=spec)

@classmethod
def update_spec(cls, spec: Dict[str, Callable[[Any], Any]]) -> None:
for alias, name in cls.aliases.items():
# Flags don't have a default
spec[alias] = directives.flag if name not in cls.defaults else directives.unchanged
def create_spec(cls) -> OptionSpec:

# spec from CodeBlock
this_spec: OptionSpec = {}
for name, directive in CodeBlock.option_spec.items():
if name in cls.drop_code_block_option:
continue
this_spec[cls._name_to_alias(name)] = directive

# own flags after the default flags so we overwrite them in case we have duplicate names
for name, default in cls.defaults.items():
# all own options are currently strings
if isinstance(default, str):
this_spec[cls._name_to_alias(name)] = directives.unchanged
elif isinstance(default, bool):
this_spec[cls._name_to_alias(name)] = directives.flag
else:
msg = f'Unsupported type {type(default)} for default "{name:s}"!'
raise TypeError(msg)

return this_spec

def build_spec() -> Dict[str, Callable[[Any], Any]]:
spec = {}
SpecCode.update_spec(spec)
SpecOutput.update_spec(spec)

def build_spec() -> OptionSpec:
spec: OptionSpec = {}
spec.update(SpecCode.create_spec())
spec.update(SpecOutput.create_spec())
return spec


def get_specs(options: Dict[str, Any]) -> Tuple['SpecCode', 'SpecOutput']:
supported = set(SpecCode.aliases) | set(SpecOutput.aliases)
supported = set(SpecCode.create_spec()) | set(SpecOutput.create_spec())
invalid = set(options) - supported

if invalid:
Expand All @@ -60,32 +88,47 @@ def get_specs(options: Dict[str, Any]) -> Tuple['SpecCode', 'SpecOutput']:


class SpecCode(SphinxSpecBase):
aliases: ClassVar = {
'hide_code': 'hide',
'caption': 'caption',
'language': 'language',
'linenos': 'linenos',
'filename': 'filename',
}
drop_code_block_option: ClassVar = ()
defaults: ClassVar = {
'caption': '',
'language': 'python',
'filename': '',
'hide_code': False, # deprecated 2024 - remove after some time, must come before the new hide flag!
'hide': False,
'language': 'python',
}

def __init__(self, hide: bool, linenos: bool, caption: str, language: str, filename: str) -> None:
super().__init__(hide, linenos, caption, language)
self.filename: str = filename
@staticmethod
def _alias_to_name(alias: str) -> str:
if alias == 'hide_code':
log.info('The "hide_code" directive is deprecated! Use "hide" instead!')
return 'hide'
return alias

@staticmethod
def _name_to_alias(name: str) -> str:
return name

def __init__(self, **kwargs: Dict[str, Any]) -> None:
super().__init__(**kwargs)
self.filename: Final[str] = self.spec.pop('filename')


class SpecOutput(SphinxSpecBase):
aliases: ClassVar = {
'hide_output': 'hide',
'caption_output': 'caption',
'language_output': 'language',
'linenos_output': 'linenos',
}
@staticmethod
def _alias_to_name(alias: str) -> str:
if alias.endswith('_output'):
return alias[:-7]
return alias

@staticmethod
def _name_to_alias(name: str) -> str:
if name.endswith('_output'):
return name[:-7]
return name + '_output'

drop_code_block_option: ClassVar = ('name', )
defaults: ClassVar = {
'caption': '',
'hide': False,
'language': 'none',
}
Loading

0 comments on commit dc3af59

Please sign in to comment.