Skip to content

Commit 4dc6f74

Browse files
authoredOct 14, 2024··
Merge pull request #34 from sphinx-notes/refactor/ext-entrypoint2
refactor: Change extension entrypoint to sphinxnotes.snippet (2nd)
2 parents 264be70 + 31a46cb commit 4dc6f74

File tree

7 files changed

+260
-214
lines changed

7 files changed

+260
-214
lines changed
 

‎docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@
152152
# add these directories to sys.path here. If the directory is relative to the
153153
# documentation root, use os.path.abspath to make it absolute, like shown here.
154154
sys.path.insert(0, os.path.abspath('../src/sphinxnotes'))
155-
extensions.append('snippet.ext')
155+
extensions.append('snippet')
156156

157157
# DOG FOOD CONFIGURATION START
158158

‎src/sphinxnotes/snippet/__init__.py

Lines changed: 28 additions & 190 deletions
Original file line numberDiff line numberDiff line change
@@ -2,197 +2,35 @@
22
sphinxnotes.snippet
33
~~~~~~~~~~~~~~~~~~~
44
5-
:copyright: Copyright 2020 Shengyu Zhang
5+
Sphinx extension entrypoint.
6+
7+
:copyright: Copyright 2024 Shengyu Zhang
68
:license: BSD, see LICENSE for details.
79
"""
810

9-
from __future__ import annotations
10-
from typing import List, Tuple, Optional, TYPE_CHECKING
11-
import itertools
12-
from os import path
13-
14-
from docutils import nodes
15-
16-
if TYPE_CHECKING:
17-
from sphinx.environment import BuildEnvironment
18-
19-
__version__ = '1.1.1'
20-
21-
22-
class Snippet(object):
23-
"""
24-
Snippet is base class of reStructuredText snippet.
25-
26-
:param nodes: Document nodes that make up this snippet
27-
"""
28-
29-
#: docname where the snippet is located, can be referenced by
30-
# :rst:role:`doc`.
31-
docname: str
32-
33-
#: Absolute path of the source file.
34-
file: str
35-
36-
#: Line number range of snippet, in the source file which is left closed
37-
#: and right opened.
38-
lineno: Tuple[int, int]
39-
40-
#: The original reStructuredText of snippet
41-
rst: List[str]
42-
43-
#: The possible identifier key of snippet, which is picked from nodes'
44-
#: (or nodes' parent's) `ids attr`_.
45-
#:
46-
#: .. _ids attr: https://docutils.sourceforge.io/docs/ref/doctree.html#ids
47-
refid: Optional[str]
48-
49-
def __init__(self, *nodes: nodes.Node) -> None:
50-
assert len(nodes) != 0
51-
52-
env: BuildEnvironment = nodes[0].document.settings.env
53-
self.file = nodes[0].source
54-
self.docname = env.path2doc(self.file)
55-
56-
lineno = [float('inf'), -float('inf')]
57-
for node in nodes:
58-
if not node.line:
59-
continue # Skip node that have None line, I dont know why
60-
lineno[0] = min(lineno[0], _line_of_start(node))
61-
lineno[1] = max(lineno[1], _line_of_end(node))
62-
self.lineno = lineno
63-
64-
lines = []
65-
with open(self.file, 'r') as f:
66-
start = self.lineno[0] - 1
67-
stop = self.lineno[1] - 1
68-
for line in itertools.islice(f, start, stop):
69-
lines.append(line.strip('\n'))
70-
self.rst = lines
71-
72-
# Find exactly one ID attr in nodes
73-
self.refid = None
74-
for node in nodes:
75-
if node['ids']:
76-
self.refid = node['ids'][0]
77-
break
78-
79-
# If no ID found, try parent
80-
if not self.refid:
81-
for node in nodes:
82-
if node.parent['ids']:
83-
self.refid = node.parent['ids'][0]
84-
break
85-
86-
87-
class Text(Snippet):
88-
#: Text of snippet
89-
text: str
90-
91-
def __init__(self, node: nodes.Node) -> None:
92-
super().__init__(node)
93-
self.text = node.astext()
94-
95-
96-
class CodeBlock(Text):
97-
#: Language of code block
98-
language: str
99-
#: Caption of code block
100-
caption: Optional[str]
101-
102-
def __init__(self, node: nodes.literal_block) -> None:
103-
assert isinstance(node, nodes.literal_block)
104-
super().__init__(node)
105-
self.language = node['language']
106-
self.caption = node.get('caption')
107-
108-
109-
class WithCodeBlock(object):
110-
code_blocks: List[CodeBlock]
111-
112-
def __init__(self, nodes: nodes.Nodes) -> None:
113-
self.code_blocks = []
114-
for n in nodes.traverse(nodes.literal_block):
115-
self.code_blocks.append(self.CodeBlock(n))
116-
117-
118-
class Title(Text):
119-
def __init__(self, node: nodes.title) -> None:
120-
assert isinstance(node, nodes.title)
121-
super().__init__(node)
122-
123-
124-
class WithTitle(object):
125-
title: Optional[Title]
126-
127-
def __init__(self, node: nodes.Node) -> None:
128-
title_node = node.next_node(nodes.title)
129-
self.title = Title(title_node) if title_node else None
130-
131-
132-
class Section(Snippet, WithTitle):
133-
def __init__(self, node: nodes.section) -> None:
134-
assert isinstance(node, nodes.section)
135-
Snippet.__init__(self, node)
136-
WithTitle.__init__(self, node)
137-
138-
139-
class Document(Section):
140-
#: A set of absolute paths of dependent files for document.
141-
#: Obtained from :attr:`BuildEnvironment.dependencies`.
142-
deps: set[str]
143-
144-
def __init__(self, node: nodes.document) -> None:
145-
assert isinstance(node, nodes.document)
146-
super().__init__(node.next_node(nodes.section))
147-
148-
# Record document's dependent files
149-
self.deps = set()
150-
env: BuildEnvironment = node.settings.env
151-
for dep in env.dependencies[self.docname]:
152-
# Relative to documentation root -> Absolute path of file system.
153-
self.deps.add(path.join(env.srcdir, dep))
154-
155-
156-
################
157-
# Nodes helper #
158-
################
159-
160-
161-
def _line_of_start(node: nodes.Node) -> int:
162-
assert node.line
163-
if isinstance(node, nodes.title):
164-
if isinstance(node.parent.parent, nodes.document):
165-
# Spceial case for Document Title / Subtitle
166-
return 1
167-
else:
168-
# Spceial case for section title
169-
return node.line - 1
170-
elif isinstance(node, nodes.section):
171-
if isinstance(node.parent, nodes.document):
172-
# Spceial case for top level section
173-
return 1
174-
else:
175-
# Spceial case for section
176-
return node.line - 1
177-
return node.line
178-
17911

180-
def _line_of_end(node: nodes.Node) -> Optional[int]:
181-
next_node = node.next_node(descend=False, siblings=True, ascend=True)
182-
while next_node:
183-
if next_node.line:
184-
return _line_of_start(next_node)
185-
next_node = next_node.next_node(
186-
# Some nodes' line attr is always None, but their children has
187-
# valid line attr
188-
descend=True,
189-
# If node and its children have not valid line attr, try use line
190-
# of next node
191-
ascend=True,
192-
siblings=True,
193-
)
194-
# No line found, return the max line of source file
195-
if node.source:
196-
with open(node.source) as f:
197-
return sum(1 for line in f)
198-
raise AttributeError('None source attr of node %s' % node)
12+
def setup(app):
13+
# **WARNING**: We don't import these packages globally, because the current
14+
# package (sphinxnotes.snippet) is always resloved when importing
15+
# sphinxnotes.snippet.*. If we import packages here, eventually we will
16+
# load a lot of packages from the Sphinx. It will seriously **SLOW DOWN**
17+
# the startup time of our CLI tool (sphinxnotes.snippet.cli).
18+
#
19+
# .. seealso:: https://github.com/sphinx-notes/snippet/pull/31
20+
from .ext import (
21+
SnippetBuilder,
22+
on_config_inited,
23+
on_env_get_outdated,
24+
on_doctree_resolved,
25+
on_builder_finished,
26+
)
27+
28+
app.add_builder(SnippetBuilder)
29+
30+
app.add_config_value('snippet_config', {}, '')
31+
app.add_config_value('snippet_patterns', {'*': ['.*']}, '')
32+
33+
app.connect('config-inited', on_config_inited)
34+
app.connect('env-get-outdated', on_env_get_outdated)
35+
app.connect('doctree-resolved', on_doctree_resolved)
36+
app.connect('build-finished', on_builder_finished)

‎src/sphinxnotes/snippet/cache.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import List, Tuple, Dict, Optional
1010
from dataclasses import dataclass
1111

12-
from . import Snippet
12+
from .snippets import Snippet
1313
from .utils.pdict import PDict
1414

1515

‎src/sphinxnotes/snippet/cli.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
sphinxnotes.snippet.cli
33
~~~~~~~~~~~~~~~~~~~~~~~
44
5-
:copyright: Copyright 2020 Shengyu Zhang
5+
Command line entrypoint.
6+
7+
:copyright: Copyright 2024 Shengyu Zhang
68
:license: BSD, see LICENSE for details.
79
"""
810

11+
# **NOTE**: Import new packages with caution:
12+
# Importing complex packages (like sphinx.*) will directly slow down the
13+
# startup of the CLI tool.
914
from __future__ import annotations
1015
import sys
1116
import argparse
@@ -16,9 +21,8 @@
1621
import posixpath
1722

1823
from xdg.BaseDirectory import xdg_config_home
19-
from sphinx.util.matching import patmatch
2024

21-
from . import __version__, Document
25+
from .snippets import Document
2226
from .config import Config
2327
from .cache import Cache, IndexID, Index
2428
from .table import tablify, COLUMNS
@@ -39,7 +43,7 @@ def get_integration_file(fn: str) -> str:
3943
.. seealso::
4044
4145
see ``[tool.setuptools.package-data]`` section of pyproject.toml to know
42-
how files are included.
46+
how files are included.
4347
"""
4448
# TODO: use https://docs.python.org/3/library/importlib.resources.html#importlib.resources.files
4549
prefix = path.abspath(path.dirname(__file__))
@@ -61,7 +65,11 @@ def main(argv: List[str] = sys.argv[1:]):
6165
* (any) wildcard for any snippet"""),
6266
)
6367
parser.add_argument(
64-
'-v', '--version', action='version', version='%(prog)s ' + __version__
68+
'--version',
69+
# add_argument provides action='version', but it requires a version
70+
# literal and doesn't support lazily obtaining version.
71+
action='store_true',
72+
help="show program's version number and exit",
6573
)
6674
parser.add_argument(
6775
'-c', '--config', default=DEFAULT_CONFIG_FILE, help='path to configuration file'
@@ -176,6 +184,16 @@ def main(argv: List[str] = sys.argv[1:]):
176184
# Parse command line arguments
177185
args = parser.parse_args(argv)
178186

187+
# Print version message.
188+
# See parser.add_argument('--version', ...) for more detais.
189+
if args.version:
190+
# NOTE: Importing is slow, do it on demand.
191+
from importlib.metadata import version
192+
193+
pkgname = 'sphinxnotes.snippet'
194+
print(pkgname, version(pkgname))
195+
parser.exit()
196+
179197
# Load config from file
180198
if args.config == DEFAULT_CONFIG_FILE and not path.isfile(DEFAULT_CONFIG_FILE):
181199
print(
@@ -219,6 +237,9 @@ def _on_command_stat(args: argparse.Namespace):
219237
def _filter_list_items(
220238
cache: Cache, tags: str, docname_glob: str
221239
) -> Iterable[Tuple[IndexID, Index]]:
240+
# NOTE: Importing is slow, do it on demand.
241+
from sphinx.util.matching import patmatch
242+
222243
for index_id, index in cache.indexes.items():
223244
# Filter by tags.
224245
if index[0] not in tags and '*' not in tags:

‎src/sphinxnotes/snippet/ext.py

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""
2-
sphinxnotes.ext.snippet
2+
sphinxnotes.snippet.ext
33
~~~~~~~~~~~~~~~~~~~~~~~
44
5-
Sphinx extension for sphinxnotes.snippet.
5+
Sphinx extension implementation, but the entrypoint is located at __init__.py.
66
7-
:copyright: Copyright 2021 Shengyu Zhang
7+
:copyright: Copyright 2024 Shengyu Zhang
88
:license: BSD, see LICENSE for details.
99
"""
1010

@@ -26,7 +26,7 @@
2626
from collections.abc import Iterator
2727

2828
from .config import Config
29-
from . import Snippet, WithTitle, Document, Section
29+
from .snippets import Snippet, WithTitle, Document, Section
3030
from .picker import pick
3131
from .cache import Cache, Item
3232
from .keyword import Extractor
@@ -206,15 +206,3 @@ def _format_modified_time(timestamp: float) -> str:
206206
"""Return an RFC 3339 formatted string representing the given timestamp."""
207207
seconds, fraction = divmod(timestamp, 1)
208208
return time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(seconds)) + f'.{fraction:.3f}'
209-
210-
211-
def setup(app: Sphinx):
212-
app.add_builder(SnippetBuilder)
213-
214-
app.add_config_value('snippet_config', {}, '')
215-
app.add_config_value('snippet_patterns', {'*': ['.*']}, '')
216-
217-
app.connect('config-inited', on_config_inited)
218-
app.connect('env-get-outdated', on_env_get_outdated)
219-
app.connect('doctree-resolved', on_doctree_resolved)
220-
app.connect('build-finished', on_builder_finished)

‎src/sphinxnotes/snippet/picker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from sphinx.util import logging
1717

18-
from . import Snippet, Section, Document
18+
from .snippets import Snippet, Section, Document
1919

2020
if TYPE_CHECKING:
2121
from sphinx.application import Sphinx

‎src/sphinxnotes/snippet/snippets.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""
2+
sphinxnotes.snippet.snippets
3+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4+
5+
Definitions of various snippets.
6+
7+
:copyright: Copyright 2024 Shengyu Zhang
8+
:license: BSD, see LICENSE for details.
9+
"""
10+
11+
from __future__ import annotations
12+
from typing import List, Tuple, Optional, TYPE_CHECKING
13+
import itertools
14+
from os import path
15+
16+
from docutils import nodes
17+
18+
if TYPE_CHECKING:
19+
from sphinx.environment import BuildEnvironment
20+
21+
22+
class Snippet(object):
23+
"""
24+
Snippet is structured fragments extracted from a single Sphinx document
25+
(can also be said to be a reStructuredText file).
26+
27+
:param nodes: nodes of doctree that make up this snippet.
28+
"""
29+
30+
#: docname where the snippet is located, can be referenced by
31+
# :rst:role:`doc`.
32+
docname: str
33+
34+
#: Absolute path of the source file.
35+
file: str
36+
37+
#: Line number range of snippet, in the source file which is left closed
38+
#: and right opened.
39+
lineno: Tuple[int, int]
40+
41+
#: The original reStructuredText of snippet
42+
rst: List[str]
43+
44+
#: The possible identifier key of snippet, which is picked from nodes'
45+
#: (or nodes' parent's) `ids attr`_.
46+
#:
47+
#: .. _ids attr: https://docutils.sourceforge.io/docs/ref/doctree.html#ids
48+
refid: Optional[str]
49+
50+
def __init__(self, *nodes: nodes.Node) -> None:
51+
assert len(nodes) != 0
52+
53+
env: BuildEnvironment = nodes[0].document.settings.env
54+
self.file = nodes[0].source
55+
self.docname = env.path2doc(self.file)
56+
57+
lineno = [float('inf'), -float('inf')]
58+
for node in nodes:
59+
if not node.line:
60+
continue # Skip node that have None line, I dont know why
61+
lineno[0] = min(lineno[0], _line_of_start(node))
62+
lineno[1] = max(lineno[1], _line_of_end(node))
63+
self.lineno = lineno
64+
65+
lines = []
66+
with open(self.file, 'r') as f:
67+
start = self.lineno[0] - 1
68+
stop = self.lineno[1] - 1
69+
for line in itertools.islice(f, start, stop):
70+
lines.append(line.strip('\n'))
71+
self.rst = lines
72+
73+
# Find exactly one ID attr in nodes
74+
self.refid = None
75+
for node in nodes:
76+
if node['ids']:
77+
self.refid = node['ids'][0]
78+
break
79+
80+
# If no ID found, try parent
81+
if not self.refid:
82+
for node in nodes:
83+
if node.parent['ids']:
84+
self.refid = node.parent['ids'][0]
85+
break
86+
87+
88+
class Text(Snippet):
89+
#: Text of snippet
90+
text: str
91+
92+
def __init__(self, node: nodes.Node) -> None:
93+
super().__init__(node)
94+
self.text = node.astext()
95+
96+
97+
class CodeBlock(Text):
98+
#: Language of code block
99+
language: str
100+
#: Caption of code block
101+
caption: Optional[str]
102+
103+
def __init__(self, node: nodes.literal_block) -> None:
104+
assert isinstance(node, nodes.literal_block)
105+
super().__init__(node)
106+
self.language = node['language']
107+
self.caption = node.get('caption')
108+
109+
110+
class WithCodeBlock(object):
111+
code_blocks: List[CodeBlock]
112+
113+
def __init__(self, nodes: nodes.Nodes) -> None:
114+
self.code_blocks = []
115+
for n in nodes.traverse(nodes.literal_block):
116+
self.code_blocks.append(self.CodeBlock(n))
117+
118+
119+
class Title(Text):
120+
def __init__(self, node: nodes.title) -> None:
121+
assert isinstance(node, nodes.title)
122+
super().__init__(node)
123+
124+
125+
class WithTitle(object):
126+
title: Optional[Title]
127+
128+
def __init__(self, node: nodes.Node) -> None:
129+
title_node = node.next_node(nodes.title)
130+
self.title = Title(title_node) if title_node else None
131+
132+
133+
class Section(Snippet, WithTitle):
134+
def __init__(self, node: nodes.section) -> None:
135+
assert isinstance(node, nodes.section)
136+
Snippet.__init__(self, node)
137+
WithTitle.__init__(self, node)
138+
139+
140+
class Document(Section):
141+
#: A set of absolute paths of dependent files for document.
142+
#: Obtained from :attr:`BuildEnvironment.dependencies`.
143+
deps: set[str]
144+
145+
def __init__(self, node: nodes.document) -> None:
146+
assert isinstance(node, nodes.document)
147+
super().__init__(node.next_node(nodes.section))
148+
149+
# Record document's dependent files
150+
self.deps = set()
151+
env: BuildEnvironment = node.settings.env
152+
for dep in env.dependencies[self.docname]:
153+
# Relative to documentation root -> Absolute path of file system.
154+
self.deps.add(path.join(env.srcdir, dep))
155+
156+
157+
################
158+
# Nodes helper #
159+
################
160+
161+
162+
def _line_of_start(node: nodes.Node) -> int:
163+
assert node.line
164+
if isinstance(node, nodes.title):
165+
if isinstance(node.parent.parent, nodes.document):
166+
# Spceial case for Document Title / Subtitle
167+
return 1
168+
else:
169+
# Spceial case for section title
170+
return node.line - 1
171+
elif isinstance(node, nodes.section):
172+
if isinstance(node.parent, nodes.document):
173+
# Spceial case for top level section
174+
return 1
175+
else:
176+
# Spceial case for section
177+
return node.line - 1
178+
return node.line
179+
180+
181+
def _line_of_end(node: nodes.Node) -> Optional[int]:
182+
next_node = node.next_node(descend=False, siblings=True, ascend=True)
183+
while next_node:
184+
if next_node.line:
185+
return _line_of_start(next_node)
186+
next_node = next_node.next_node(
187+
# Some nodes' line attr is always None, but their children has
188+
# valid line attr
189+
descend=True,
190+
# If node and its children have not valid line attr, try use line
191+
# of next node
192+
ascend=True,
193+
siblings=True,
194+
)
195+
# No line found, return the max line of source file
196+
if node.source:
197+
with open(node.source) as f:
198+
return sum(1 for line in f)
199+
raise AttributeError('None source attr of node %s' % node)

0 commit comments

Comments
 (0)
Please sign in to comment.