Skip to content

Commit 9d61262

Browse files
committed
DOCSP-5545: Implement release specifications
* Add a ReleaseSpecificationsGizaCategory class * Merge source_constants into the giza substitutions system * Add tests * Raise a diagnostic upon use of undefined giza substitutions
1 parent 2ea2feb commit 9d61262

File tree

11 files changed

+238
-29
lines changed

11 files changed

+238
-29
lines changed

snooty/gizaparser/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from . import steps, extracts # NoQA
1+
from . import steps, extracts, release # NoQA

snooty/gizaparser/extracts.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,12 @@ def to_pages(self,
5757
extracts: Sequence[Extract]) -> List[Page]:
5858
pages: List[Page] = []
5959
for extract in extracts:
60+
assert extract.ref is not None
61+
if extract.ref.startswith('_'):
62+
continue
63+
6064
page, rst_parser = page_factory()
6165
page.category = 'extracts'
62-
assert extract.ref is not None
6366
page.output_filename = extract.ref
6467
page.ast = extract_to_page(page, extract, rst_parser)
6568
pages.append(page)

snooty/gizaparser/nodes.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from dataclasses import dataclass, field
66
from pathlib import Path, PurePath
77
from typing import cast, Callable, Dict, Set, Generic, Optional, \
8-
TypeVar, Tuple, Iterator, Sequence, List, Union
8+
TypeVar, Tuple, Iterator, Sequence, List, Union, \
9+
Match
910
from ..flutter import checked
1011
from ..types import Diagnostic, Page, EmbeddedRstParser, SerializableType, ProjectConfig
1112

@@ -14,13 +15,26 @@
1415
logger = logging.getLogger(__name__)
1516

1617

17-
def substitute_text(text: str, replacements: Dict[str, str]) -> str:
18-
return PAT_SUBSTITUTION.sub(lambda match: replacements.get(match.group(1), ''), text)
18+
def substitute_text(text: str,
19+
replacements: Dict[str, str],
20+
diagnostics: List[Diagnostic]) -> str:
21+
def substitute(match: Match[str]) -> str:
22+
try:
23+
return replacements[match.group(1)]
24+
except KeyError:
25+
diagnostics.append(Diagnostic.warning(
26+
f'Unknown substitution: "{match.group(1)}". ' +
27+
'You may intend this substitution to be empty', 1))
28+
return ''
1929

30+
return PAT_SUBSTITUTION.sub(substitute, text)
2031

21-
def substitute(obj: _T, replacements: Dict[str, str]) -> _T:
32+
33+
def substitute(obj: _T,
34+
replacements: Dict[str, str],
35+
diagnostics: List[Diagnostic]) -> _T:
2236
if isinstance(obj, str):
23-
return substitute_text(obj, replacements)
37+
return substitute_text(obj, replacements, diagnostics)
2438

2539
if not dataclasses.is_dataclass(obj):
2640
return obj
@@ -29,11 +43,11 @@ def substitute(obj: _T, replacements: Dict[str, str]) -> _T:
2943
for obj_field in dataclasses.fields(obj):
3044
value = getattr(obj, obj_field.name)
3145
if isinstance(value, str):
32-
new_str = substitute_text(value, replacements)
46+
new_str = substitute_text(value, replacements, diagnostics)
3347
if new_str is not value:
3448
changes[obj_field.name] = new_str
3549
elif dataclasses.is_dataclass(value):
36-
new_value = substitute(value, replacements)
50+
new_value = substitute(value, replacements, diagnostics)
3751
if new_value is not value:
3852
changes[obj_field.name] = new_value
3953

@@ -65,7 +79,10 @@ class Inheritable(Node):
6579
_I = TypeVar('_I', bound=Inheritable)
6680

6781

68-
def inherit(obj: _I, parent: Optional[_I]) -> _I:
82+
def inherit(project_config: ProjectConfig,
83+
obj: _I,
84+
parent: Optional[_I],
85+
diagnostics: List[Diagnostic]) -> _I:
6986
logger.debug('Inheriting %s', obj.ref)
7087
changes: Dict[str, object] = {}
7188

@@ -77,6 +94,11 @@ def inherit(obj: _I, parent: Optional[_I]) -> _I:
7794
if src not in replacement:
7895
replacement[src] = dest
7996

97+
# Merge in project-wide constants into the giza substitutions system
98+
new_replacement = {k: str(v) for k, v in project_config.constants.items()}
99+
new_replacement.update(replacement)
100+
replacement = new_replacement
101+
80102
# Inherit root-level keys
81103
for field_name in (field.name for field in dataclasses.fields(obj)
82104
if field.name not in {'replacement', 'ref', 'source', 'inherit'}):
@@ -87,8 +109,8 @@ def inherit(obj: _I, parent: Optional[_I]) -> _I:
87109
changes[field_name] = new_value
88110
value = new_value
89111

90-
if replacement and value is not None:
91-
changes[field_name] = substitute(value, replacement)
112+
if value is not None:
113+
changes[field_name] = substitute(value, replacement, diagnostics)
92114

93115
return dataclasses.replace(obj, **changes) if changes else obj
94116

@@ -187,7 +209,7 @@ def reify(self, obj: _I, diagnostics: List[Diagnostic]) -> _I:
187209
if obj.ref is None:
188210
obj.ref = ''
189211

190-
obj = inherit(obj, parent)
212+
obj = inherit(self.project_config, obj, parent, diagnostics)
191213
return obj
192214

193215
def reify_file_id(self,

snooty/gizaparser/release.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from dataclasses import dataclass
2+
from pathlib import Path
3+
from typing import Callable, Optional, List, Tuple, Sequence
4+
from ..flutter import checked
5+
from ..types import Diagnostic, EmbeddedRstParser, SerializableType, Page
6+
from .parse import parse
7+
from .nodes import Inheritable, GizaCategory
8+
9+
10+
@checked
11+
@dataclass
12+
class ReleaseSpecification(Inheritable):
13+
copyable: Optional[bool]
14+
language: Optional[str]
15+
code: Optional[str]
16+
17+
def render(self, page: Page, parse_rst: EmbeddedRstParser) -> SerializableType:
18+
children: List[SerializableType] = []
19+
if self.code:
20+
children.append({
21+
'type': 'code',
22+
'lang': self.language,
23+
'copyable': True if self.copyable is None else self.copyable,
24+
'position': {'start': {'line': self.line}},
25+
'value': self.code
26+
})
27+
return children
28+
29+
30+
def release_specification_to_page(page: Page,
31+
node: ReleaseSpecification,
32+
rst_parser: EmbeddedRstParser) -> SerializableType:
33+
rendered = node.render(page, rst_parser)
34+
return {
35+
'type': 'directive',
36+
'name': 'release_specification',
37+
'position': {'start': {'line': node.line}},
38+
'children': rendered
39+
}
40+
41+
42+
class GizaReleaseSpecificationCategory(GizaCategory[ReleaseSpecification]):
43+
def parse(self,
44+
path: Path,
45+
text: Optional[str] = None) -> Tuple[
46+
Sequence[ReleaseSpecification], str, List[Diagnostic]]:
47+
nodes, text, diagnostics = parse(ReleaseSpecification, path, self.project_config, text)
48+
49+
def report_missing_ref(node: ReleaseSpecification) -> bool:
50+
diagnostics.append(
51+
Diagnostic.error(
52+
'Missing ref; all release specifications must define a ref', node.line))
53+
return False
54+
55+
# All nodes must have an explicitly-defined ref ID
56+
release_specifications = [node for node in nodes if node.ref or report_missing_ref(node)]
57+
return release_specifications, text, diagnostics
58+
59+
def to_pages(self,
60+
page_factory: Callable[[], Tuple[Page, EmbeddedRstParser]],
61+
nodes: Sequence[ReleaseSpecification]) -> List[Page]:
62+
pages: List[Page] = []
63+
for node in nodes:
64+
assert node.ref is not None
65+
if node.ref.startswith('_'):
66+
continue
67+
68+
page, rst_parser = page_factory()
69+
page.category = 'release'
70+
page.output_filename = node.ref
71+
page.ast = release_specification_to_page(page, node, rst_parser)
72+
pages.append(page)
73+
74+
return pages

snooty/gizaparser/test_extracts.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def add_main_file() -> List[Diagnostic]:
1818
assert len(parse_diagnostics) == 1
1919
assert parse_diagnostics[0].severity == Diagnostic.Level.error
2020
assert parse_diagnostics[0].start == (21, 0)
21-
assert len(extracts) == 4
21+
assert len(extracts) == 5
2222
return parse_diagnostics
2323

2424
def add_parent_file() -> List[Diagnostic]:
@@ -41,22 +41,21 @@ def create_page() -> Tuple[Page, EmbeddedRstParser]:
4141

4242
pages = category.to_pages(create_page, giza_node.data)
4343
assert [str(page.get_id()) for page in pages] == [
44-
'test_data/extracts/_base',
4544
'test_data/extracts/installation-directory-rhel',
4645
'test_data/extracts/broken-inherit',
47-
'test_data/extracts/another-file']
48-
assert ast_to_testing_string(pages[0].ast) == ''.join((
49-
'<directive name="extract"><paragraph><text>By default, MongoDB stores its data files in ',
50-
'{{mongodDatadir}} and its\nlog files in </text><literal><text>/var/log/mongodb</text>',
51-
'</literal><text>.</text></paragraph></directive>'
52-
))
46+
'test_data/extracts/another-file',
47+
'test_data/extracts/missing-substitution']
5348

54-
assert ast_to_testing_string(pages[1].ast) == ''.join((
49+
assert ast_to_testing_string(pages[0].ast) == ''.join((
5550
'<directive name="extract"><paragraph><text>By default, MongoDB stores its data files in ',
5651
'</text><literal><text>/var/lib/mongo</text></literal><text> and its\nlog files in </text>',
5752
'<literal><text>/var/log/mongodb</text></literal><text>.</text></paragraph></directive>'
5853
))
5954

55+
assert ast_to_testing_string(pages[3].ast) == ''.join((
56+
'<directive name="extract"><paragraph><text>Substitute</text></paragraph></directive>'
57+
))
58+
6059
# XXX: We need to track source file information for each property.
6160
# Line number 1 here should correspond to parent_path, not path.
6261
assert set(d.start[0] for d in all_diagnostics[path]) == set((21, 13, 1))

snooty/gizaparser/test_nodes.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from dataclasses import dataclass
2+
from pathlib import Path
3+
from typing import List
24
from . import nodes
5+
from ..types import Diagnostic, ProjectConfig
36

47

58
@dataclass
@@ -30,44 +33,51 @@ def test_dependency_graph() -> None:
3033

3134

3235
def test_substitution() -> None:
36+
diagnostics: List[Diagnostic] = []
3337
replacements = {
3438
'verb': 'test',
3539
'noun': 'substitution'
3640
}
3741
test_string = r'{{verb}}ing {{noun}}. {{verb}}.'
3842
substituted_string = 'testing substitution. test.'
39-
assert nodes.substitute_text(test_string, replacements) == substituted_string
43+
assert nodes.substitute_text(test_string, replacements, diagnostics) == substituted_string
4044

4145
obj = object()
42-
assert nodes.substitute(obj, replacements) is obj
46+
assert nodes.substitute(obj, replacements, diagnostics) is obj
4347

4448
# Test complex substitution
4549
node = SubstitutionTest(
4650
foo=test_string,
4751
foo2=test_string,
4852
child=Child(test_string))
49-
substituted_node = nodes.substitute(node, replacements)
53+
substituted_node = nodes.substitute(node, replacements, diagnostics)
5054
assert substituted_node == SubstitutionTest(
5155
foo=substituted_string,
5256
foo2=substituted_string,
5357
child=Child(substituted_string))
5458

5559
# Make sure that no substitution == ''
60+
assert diagnostics == []
5661
del replacements['noun']
57-
assert nodes.substitute_text(test_string, replacements) == 'testing . test.'
62+
assert nodes.substitute_text(test_string, replacements, diagnostics) == 'testing . test.'
63+
assert len(diagnostics) == 1
5864

5965
# Ensure the identity of the zero-substitutions case remains the same
66+
diagnostics = []
6067
test_string = 'foo'
61-
assert nodes.substitute_text(test_string, {}) is test_string
68+
assert nodes.substitute_text(test_string, {}, diagnostics) is test_string
69+
assert not diagnostics
6270

6371

6472
def test_inheritance() -> None:
73+
project_config, diagnostics = ProjectConfig.open(Path('test_data'))
6574
parent = nodes.Inheritable('parent', {'foo': 'bar', 'old': ''}, source=None, inherit=None)
6675
child = nodes.Inheritable(
6776
'child',
6877
{'bar': 'baz', 'old': 'new'},
6978
source=nodes.Inherit('self.yaml', 'parent'),
7079
inherit=None)
71-
child = nodes.inherit(child, parent)
80+
child = nodes.inherit(project_config, child, parent, diagnostics)
7281

7382
assert child.replacement == {'foo': 'bar', 'bar': 'baz', 'old': 'new'}
83+
assert not diagnostics

snooty/gizaparser/test_release.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from pathlib import Path, PurePath
2+
from typing import Dict, Tuple, List
3+
from .release import GizaReleaseSpecificationCategory
4+
from ..types import Diagnostic, Page, EmbeddedRstParser, ProjectConfig
5+
from ..parser import make_embedded_rst_parser
6+
from ..util import ast_to_testing_string
7+
8+
9+
def test_release_specification() -> None:
10+
project_config = ProjectConfig(Path('test_data'), '')
11+
project_config.constants['version'] = '3.4'
12+
category = GizaReleaseSpecificationCategory(project_config)
13+
path = Path('test_data/release-specifications.yaml')
14+
parent_path = Path('test_data/release-base.yaml')
15+
16+
def add_main_file() -> List[Diagnostic]:
17+
extracts, text, parse_diagnostics = category.parse(path)
18+
category.add(path, text, extracts)
19+
assert len(parse_diagnostics) == 0
20+
assert len(extracts) == 2
21+
return parse_diagnostics
22+
23+
def add_parent_file() -> List[Diagnostic]:
24+
extracts, text, parse_diagnostics = category.parse(parent_path)
25+
category.add(parent_path, text, extracts)
26+
assert len(parse_diagnostics) == 0
27+
assert len(extracts) == 2
28+
return parse_diagnostics
29+
30+
all_diagnostics: Dict[PurePath, List[Diagnostic]] = {}
31+
all_diagnostics[path] = add_main_file()
32+
all_diagnostics[parent_path] = add_parent_file()
33+
34+
assert len(category) == 2
35+
file_id, giza_node = next(category.reify_all_files(all_diagnostics))
36+
37+
def create_page() -> Tuple[Page, EmbeddedRstParser]:
38+
page = Page(path, '', {})
39+
return page, make_embedded_rst_parser(project_config, page, all_diagnostics[path])
40+
41+
pages = category.to_pages(create_page, giza_node.data)
42+
assert [str(page.get_id()) for page in pages] == [
43+
'test_data/release/untar-release-osx-x86_64',
44+
'test_data/release/install-ent-windows-default']
45+
46+
assert all((not diagnostics for diagnostics in all_diagnostics.values()))
47+
48+
print(ast_to_testing_string(pages[0].ast))
49+
assert ast_to_testing_string(pages[0].ast) == ''.join((
50+
'<directive name="release_specification">',
51+
'<code lang="sh" copyable="True">',
52+
'tar -zxvf mongodb-macos-x86_64-3.4.tgz\n'
53+
'</code>',
54+
'</directive>'
55+
))
56+
57+
print(ast_to_testing_string(pages[1].ast))
58+
assert ast_to_testing_string(pages[1].ast) == ''.join((
59+
'<directive name="release_specification">',
60+
'<code lang="bat" copyable="True">',
61+
'msiexec.exe /l*v mdbinstall.log /qb /i ',
62+
'mongodb-win32-x86_64-enterprise-windows-64-3.4-signed.msi\n',
63+
'</code>',
64+
'</directive>'
65+
))

snooty/parser.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,8 @@ def __init__(self,
344344

345345
self.yaml_mapping: Dict[str, GizaCategory[Any]] = {
346346
'steps': gizaparser.steps.GizaStepsCategory(self.config),
347-
'extracts': gizaparser.extracts.GizaExtractsCategory(self.config)
347+
'extracts': gizaparser.extracts.GizaExtractsCategory(self.config),
348+
'release': gizaparser.release.GizaReleaseSpecificationCategory(self.config),
348349
}
349350

350351
username = pwd.getpwuid(os.getuid()).pw_name

test_data/extracts-test.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,8 @@ ref: another-file
2525
inherit:
2626
file: extracts-test-parent.yaml
2727
ref: a-parent-ref
28+
---
29+
ref: missing-substitution
30+
content: |
31+
Substitute {{mongodDatadir}}
2832
...

test_data/release-base.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
ref: _untar-release
2+
copyable: true
3+
language: 'sh'
4+
code: |
5+
tar -zxvf mongodb-{{platform}}-{{builder}}-{{version}}.tgz
6+
---
7+
ref: _install-windows-ent
8+
copyable: true
9+
language: bat
10+
code: |
11+
msiexec.exe /l*v mdbinstall.log /qb /i mongodb-win32-x86_64-enterprise-windows-64-{{version}}-signed.msi
12+
13+
# Adding SHOULD_INSTALL_COMPASS="0" since the install page states that this installs just what's specified in ADDLOCAL
14+
15+
...

0 commit comments

Comments
 (0)