senfd 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
senfd/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.4.1"
senfd/cli.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ Command-Line Interface
3
+ ======================
4
+
5
+ Produces organized and semantically enriched ``.json`` documents from
6
+ "figure-documents".
7
+ """
8
+
9
+ from argparse import ArgumentParser, Namespace
10
+ from pathlib import Path
11
+
12
+ import senfd
13
+ import senfd.schema
14
+ from senfd.documents import CategorizedFigureDocument
15
+
16
+
17
+ def parse_args() -> Namespace:
18
+ """Return command-line arguments"""
19
+
20
+ parser = ArgumentParser(description="Semantically organize and enrich figures")
21
+ parser.add_argument(
22
+ "document", nargs="*", type=Path, help="path to one or more .json document(s)"
23
+ )
24
+ parser.add_argument(
25
+ "--output",
26
+ type=Path,
27
+ help="directory where the output will be saved",
28
+ default=Path("output"),
29
+ )
30
+ parser.add_argument(
31
+ "--skip-validate",
32
+ action="store_true",
33
+ help="skip post-parse validation",
34
+ )
35
+ parser.add_argument(
36
+ "--dump-schema",
37
+ action="store_true",
38
+ help=f"dump schema({senfd.schema.FILENAME}) and exit",
39
+ )
40
+ parser.add_argument(
41
+ "--version",
42
+ action="store_true",
43
+ help="print the version and exit",
44
+ )
45
+ args = parser.parse_args()
46
+ if not args.document and not args.dump_schema and not args.version:
47
+ parser.error("the following arguments are required: document")
48
+
49
+ return args
50
+
51
+
52
+ def main() -> int:
53
+ """Command-line entrypoint"""
54
+
55
+ args = parse_args()
56
+ args.output.mkdir(parents=True, exist_ok=True)
57
+
58
+ if args.version:
59
+ print(senfd.__version__)
60
+ return 0
61
+
62
+ if args.dump_schema:
63
+ CategorizedFigureDocument.to_schema_file(
64
+ args.output / CategorizedFigureDocument.FILENAME_SCHEMA
65
+ )
66
+ return 0
67
+
68
+ for path in args.document:
69
+ document = CategorizedFigureDocument.from_figure_document_file(path)
70
+ document.to_json_file(
71
+ args.output
72
+ / Path(path.name).with_suffix(CategorizedFigureDocument.SUFFIX_JSON).name
73
+ )
74
+
75
+ if args.skip_validate:
76
+ continue
77
+
78
+ if not document.is_valid():
79
+ print("Document is invalid, see above for details.")
80
+
81
+ return 0
senfd/documents.py ADDED
@@ -0,0 +1,228 @@
1
+ """
2
+ Documents
3
+ =========
4
+
5
+ The following classes model documents at different stages of parsing / processing an
6
+ NVMe specification document from a raw extract into something semantically rich.
7
+
8
+ """
9
+
10
+ import inspect
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+ from typing import ClassVar, Dict, List, Optional
15
+
16
+ from jinja2 import Environment, PackageLoader, select_autoescape
17
+ from pydantic import BaseModel, Field, ValidationError
18
+
19
+ import senfd.figures
20
+ import senfd.tables
21
+
22
+ TRANSLATION_TABLE: Dict[int, str] = str.maketrans(
23
+ {
24
+ "–": "-", # en dash
25
+ "—": "-", # em dash
26
+ "‘": "'", # left single quotation mark
27
+ "’": "'", # right single quotation mark
28
+ "“": '"', # left double quotation mark
29
+ "”": '"', # right double quotation mark
30
+ "…": "...", # ellipsis
31
+ "é": "e", # e with acute accent
32
+ "á": "a", # a with acute accent
33
+ "í": "i", # i with acute accent
34
+ "ó": "o", # o with acute accent
35
+ "ú": "u", # u with acute accent
36
+ "ü": "u", # u with diaeresis
37
+ "ñ": "n", # n with tilde
38
+ "ç": "c", # c with cedilla
39
+ }
40
+ )
41
+
42
+
43
+ class DocumentMeta(BaseModel):
44
+ version: str = senfd.__version__
45
+ stem: str = Field(default_factory=str)
46
+
47
+
48
+ class Document(BaseModel):
49
+ """
50
+ Base document - providing functionality for describing and persisting documents
51
+
52
+ The intent here is that all Documents should be representable as JSON and HTML
53
+ """
54
+
55
+ SUFFIX_JSON: ClassVar[str] = ".document.json"
56
+ SUFFIX_HTML: ClassVar[str] = ".document.html"
57
+
58
+ FILENAME_SCHEMA: ClassVar[str] = "document.schema.json"
59
+ FILENAME_HTML_TEMPLATE: ClassVar[str] = "document.html.jinja2"
60
+
61
+ meta: DocumentMeta = Field(default_factory=DocumentMeta)
62
+
63
+ @classmethod
64
+ def to_schema_file(cls, path: Optional[Path] = None):
65
+ """Writes the document JSON schema to file at the given 'path'"""
66
+
67
+ if path is None:
68
+ path = Path.cwd() / cls.FILENAME_HTML_TEMPLATE
69
+
70
+ with path.open("w") as schema_file:
71
+ json.dump(cls.schema(), schema_file, indent=4)
72
+
73
+ def get_json_filename(self):
74
+ return f"{self.meta.stem}.{self.SUFFIX_JSON}"
75
+
76
+ def to_json(self) -> str:
77
+ """Returns the document as a JSON-formated string"""
78
+
79
+ return self.model_dump_json(indent=4)
80
+
81
+ def to_json_file(self, path: Optional[Path] = None):
82
+ """Writes the document, formated as JSON, to file at the given 'path'"""
83
+
84
+ if path is None:
85
+ path = (Path.cwd() / self.meta.stem).with_suffix(self.SUFFIX_JSON)
86
+ path.write_text(self.to_json())
87
+
88
+ def get_html_filename(self):
89
+ return f"{self.meta.stem}.{self.SUFFIX_HTML}"
90
+
91
+ def to_html(self) -> str:
92
+ """Returns the document as a HTML-formatted string"""
93
+
94
+ env = Environment(
95
+ loader=PackageLoader("senfd", "templates"),
96
+ autoescape=select_autoescape(["html", "xml"]),
97
+ )
98
+ template = env.get_template(self.FILENAME_HTML_TEMPLATES)
99
+
100
+ return template.render(document=self)
101
+
102
+ def to_html_file(self, path: Optional[Path] = None):
103
+ """Writes the document to HTML-formatted file"""
104
+
105
+ if path is None:
106
+ path = (Path.cwd() / self.meta.stem).with_suffix(self.SUFFIX_HTML)
107
+ path.write_text(self.to_html())
108
+
109
+ def is_valid(self):
110
+ """Returns True when validator raises no exceptions, False otherwise"""
111
+
112
+ try:
113
+ self.validate(self.dict())
114
+ except ValidationError as e:
115
+ print(e)
116
+ return False
117
+
118
+ return True
119
+
120
+
121
+ class FigureDocument(Document):
122
+
123
+ SUFFIX_JSON: ClassVar[str] = ".figure.document.json"
124
+ SUFFIX_HTML: ClassVar[str] = ".figure.document.html"
125
+
126
+ FILENAME_SCHEMA: ClassVar[str] = "figure.document.schema.json"
127
+ FILENAME_HTML_TEMPLATE: ClassVar[str] = "figure.document.html.jinja2"
128
+
129
+ figures: List[senfd.figures.Figure] = Field(default_factory=list)
130
+
131
+
132
+ class CategorizedFigureDocumentMeta(DocumentMeta):
133
+ tabular: List[int] = Field(default_factory=list)
134
+ nontabular: List[senfd.figures.Figure] = Field(default_factory=list)
135
+ uncategorized: List[senfd.figures.Figure] = Field(default_factory=list)
136
+
137
+
138
+ class CategorizedFigureDocument(Document):
139
+
140
+ SUFFIX_JSON: ClassVar[str] = ".categorized.figure.document.json"
141
+ SUFFIX_HTML: ClassVar[str] = ".categorized.figure.document.html"
142
+
143
+ FILENAME_SCHEMA: ClassVar[str] = "categorized.figure.document.schema.json"
144
+ FILENAME_HTML_TEMPLATE: ClassVar[str] = "categorized.figure.document.html.jinja2"
145
+
146
+ meta: CategorizedFigureDocumentMeta = Field(
147
+ default_factory=CategorizedFigureDocumentMeta
148
+ )
149
+ acronyms: List[senfd.figures.Acronyms] = Field(default_factory=list)
150
+ io_controller_command_set_support: List[
151
+ senfd.figures.IoControllerCommandSetSupport
152
+ ] = Field(default_factory=list)
153
+ command_set_opcodes: List[senfd.figures.CommandSetOpcodes] = Field(
154
+ default_factory=list
155
+ )
156
+ command_support_requirements: List[senfd.figures.CommandSupportRequirements] = (
157
+ Field(default_factory=list)
158
+ )
159
+ command_sqe_dword: List[senfd.figures.CommandSqeDword] = Field(default_factory=list)
160
+ command_sqe_dwords: List[senfd.figures.CommandSqeDwords] = Field(
161
+ default_factory=list
162
+ )
163
+ command_sqe_data_pointer: List[senfd.figures.CommandSqeDataPointer] = Field(
164
+ default_factory=list
165
+ )
166
+ command_cqe_dword: List[senfd.figures.CommandCqeDword] = Field(default_factory=list)
167
+ command_specific_status_values: List[senfd.figures.CommandSpecificStatusValues] = (
168
+ Field(default_factory=list)
169
+ )
170
+ general_command_status_values: List[senfd.figures.GeneralCommandStatusValues] = (
171
+ Field(default_factory=list)
172
+ )
173
+ cns_values: List[senfd.figures.CnsValues] = Field(default_factory=list)
174
+ feature_support: List[senfd.figures.FeatureSupport] = Field(default_factory=list)
175
+ feature_identifiers: List[senfd.figures.FeatureIdentifiers] = Field(
176
+ default_factory=list
177
+ )
178
+ log_page_identifiers: List[senfd.figures.LogPageIdentifiers] = Field(
179
+ default_factory=list
180
+ )
181
+ offset: List[senfd.figures.Offset] = Field(default_factory=list)
182
+ property_definition: List[senfd.figures.PropertyDefinition] = Field(
183
+ default_factory=list
184
+ )
185
+
186
+ @classmethod
187
+ def from_figure_document_file(cls, path: Path):
188
+ """Instantiate an 'organized' Document from a 'figure' document"""
189
+
190
+ def get_figure_enriching_classes():
191
+ """
192
+ To avoid manually crafting a list of classes, this function
193
+ introspectively examines this module for applicable
194
+ classes with "REGEX_FIGURE_DESCRIPTION" class attribute.
195
+ """
196
+ return [
197
+ cls
198
+ for _, cls in inspect.getmembers(senfd.figures, inspect.isclass)
199
+ if issubclass(cls, senfd.figures.FromFigureDescriptionMatch)
200
+ and hasattr(cls, "REGEX_FIGURE_DESCRIPTION")
201
+ ]
202
+
203
+ figure_document = senfd.documents.FigureDocument.parse_file(path)
204
+
205
+ document = cls()
206
+
207
+ figure_organizers = get_figure_enriching_classes()
208
+ for figure in figure_document.figures:
209
+ if not figure.table:
210
+ document.meta.nontabular.append(figure)
211
+ continue
212
+
213
+ match = None
214
+ description = figure.description.translate(TRANSLATION_TABLE)
215
+ for candidate in figure_organizers:
216
+ match = re.match(
217
+ candidate.REGEX_FIGURE_DESCRIPTION, description, flags=re.IGNORECASE
218
+ )
219
+ if match:
220
+ obj = candidate.from_figure_description(figure, match)
221
+ obj.into_document(document)
222
+ document.meta.tabular.append(obj.figure_nr)
223
+ break
224
+
225
+ if not match:
226
+ document.meta.uncategorized.append(figure)
227
+
228
+ return document
senfd/figures.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ Figures
3
+ =======
4
+
5
+ The following classses model the figures found in documents.
6
+ """
7
+
8
+ import re
9
+ from typing import ClassVar, Optional
10
+
11
+ from pydantic import BaseModel
12
+
13
+ import senfd.tables
14
+ from senfd.utils import pascal_to_snake
15
+
16
+
17
+ class Figure(BaseModel):
18
+ """
19
+ A figure as captioned in a table of figures in the NVMe Specification Documents.
20
+
21
+ This is a minimally enriched figure representation. The intent is that via regular
22
+ expressions, it should be possible to construct instances when feeding it with
23
+ the text from a table of figures or a table that conventionally contains a figure
24
+ caption in the first row.
25
+
26
+ The page number is only retrievable from table of figure captions and thus optional.
27
+ The table data is similarly represented in a form with minimal enrichment.
28
+ """
29
+
30
+ REGEX_TABLE_OF_FIGURES: ClassVar[str] = (
31
+ r"^(?P<caption>Figure\s+(?P<figure_nr>\d+)\s*:"
32
+ r"\s*(?P<description>.*?))(?P<page_nr>\d+)?$"
33
+ )
34
+ REGEX_TABLE_ROW: ClassVar[str] = (
35
+ r"^(?P<caption>Figure\s+(?P<figure_nr>\d+)\s*:" r"\s*(?P<description>.*?))$"
36
+ )
37
+ figure_nr: int # Figure as numbered in the specification document
38
+ caption: str # The entire figure caption
39
+ description: str # The part of figure caption without the "Fig X:" prefix
40
+
41
+ page_nr: Optional[int] = None
42
+ table: Optional[senfd.tables.Table] = None
43
+
44
+ @classmethod
45
+ def from_regex(cls, regex, text):
46
+ match = re.match(regex, text)
47
+ if not match:
48
+ return None
49
+
50
+ data = {
51
+ "figure_nr": int(match.group("figure_nr")),
52
+ "caption": match.group("caption").strip(),
53
+ "description": match.group("description").strip(),
54
+ "table": None,
55
+ "page_nr": None,
56
+ }
57
+ if "page_nr" in match.groupdict():
58
+ data["page_nr"] = int(match.group("page_nr"))
59
+
60
+ return cls(**data)
61
+
62
+
63
+ class FromFigureDescriptionMatch(Figure):
64
+
65
+ @classmethod
66
+ def from_figure_description(cls, figure, match):
67
+ shared = set(figure.dict().keys()).intersection(set(match.groupdict().keys()))
68
+ if shared:
69
+ raise RuntimeError(f"cls({cls.__name__}) has overlap({shared})")
70
+
71
+ data = figure.dict()
72
+ mdict = match.groupdict()
73
+ if mdict:
74
+ data.update(mdict if mdict else {})
75
+
76
+ return cls(**data)
77
+
78
+ def into_document(self, document):
79
+ key = pascal_to_snake(self.__class__.__name__)
80
+ getattr(document, key).append(self)
81
+
82
+
83
+ class Acronyms(FromFigureDescriptionMatch):
84
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = r".*Acronym\s+(definitions|Descriptions)"
85
+
86
+
87
+ class IoControllerCommandSetSupport(FromFigureDescriptionMatch):
88
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = (
89
+ r".*-\s+(?P<command_set_name>.*)Command\s+Set\s+Support"
90
+ )
91
+ command_set_name: str
92
+
93
+
94
+ class CnsValues(FromFigureDescriptionMatch):
95
+ """Enrichment of a: 'Command Dword N' table"""
96
+
97
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = r".*CNS\s+Values.*"
98
+
99
+
100
+ class CommandSupportRequirements(FromFigureDescriptionMatch):
101
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = (
102
+ r"\s*(?P<command_span>.*)\s+Command\s*Support\s*Requirements.*"
103
+ )
104
+ command_span: str
105
+
106
+
107
+ class CommandSqeDataPointer(FromFigureDescriptionMatch):
108
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = (
109
+ r"(?P<command_name>[\w\s]+)\s+-\s+Data\s+Pointer"
110
+ )
111
+ command_name: str
112
+
113
+
114
+ class CommandSqeDwords(FromFigureDescriptionMatch):
115
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = (
116
+ r"(?P<command_name>[\w\s]+)\s*-\s*Command\s*Dword\s*"
117
+ r"(?P<command_dword_lower>\d+)"
118
+ r".*and.*?\s(?P<command_dword_upper>\d+)"
119
+ )
120
+ command_name: str
121
+ command_dword_lower: str
122
+ command_dword_upper: Optional[str]
123
+
124
+
125
+ class CommandSqeDword(FromFigureDescriptionMatch):
126
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = (
127
+ r"(?P<command_name>[\w\s]+)\s*-\s*Command\s*Dword\s*(?P<command_dword>\d+).*?"
128
+ )
129
+ command_name: str
130
+ command_dword: str
131
+
132
+
133
+ class CommandCqeDword(FromFigureDescriptionMatch):
134
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = (
135
+ r"(?P<command_name>[\w\s]+)\s+-\s+"
136
+ r"Completion\sQueue\sEntry\sDword\s(?P<command_dword>\d+)"
137
+ )
138
+ command_name: str
139
+ command_dword: str
140
+
141
+
142
+ class CommandSetOpcodes(FromFigureDescriptionMatch):
143
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = (
144
+ r"Opcodes\sfor\s(?P<command_set_name>.*)\sCommands"
145
+ )
146
+ command_set_name: str
147
+
148
+
149
+ class GeneralCommandStatusValues(FromFigureDescriptionMatch):
150
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = (
151
+ r".*(Generic|General)\s+Command\s+Status\s+Values.*"
152
+ )
153
+
154
+
155
+ class CommandSpecificStatusValues(FromFigureDescriptionMatch):
156
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = (
157
+ r"(?P<command_name>[\w\s]+)\s+-\s+Command\s+Specific\s+Status\s+Values"
158
+ )
159
+ command_name: str
160
+
161
+
162
+ class FeatureIdentifiers(FromFigureDescriptionMatch):
163
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = r".*Feature\s*Identifiers.*"
164
+
165
+
166
+ class FeatureSupport(FromFigureDescriptionMatch):
167
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = r".*Feature\s*Support.*"
168
+
169
+
170
+ class LogPageIdentifiers(FromFigureDescriptionMatch):
171
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = r".*Log\s+Page\s+Identifiers.*"
172
+
173
+
174
+ class Offset(FromFigureDescriptionMatch):
175
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = r".*offset"
176
+
177
+
178
+ class PropertyDefinition(FromFigureDescriptionMatch):
179
+ REGEX_FIGURE_DESCRIPTION: ClassVar[str] = r".*Property Definition.*"
@@ -0,0 +1 @@
1
+ FILENAME = "enriched.figures.schema.json"
@@ -0,0 +1,21 @@
1
+ """
2
+
3
+ """
4
+
5
+ import importlib.resources as pkg_resources
6
+ import json
7
+ from typing import Any, Dict
8
+
9
+ import senfd.schema
10
+
11
+
12
+ def get_schema() -> Dict[str, Any]:
13
+ """
14
+ Load the JSON Schema for the stage2 document from the package as a dict
15
+
16
+ Returns:
17
+ Dict[str, Any]: The JSON Schema as a dictionary.
18
+ """
19
+
20
+ with pkg_resources.open_text(senfd.schema, "enhanced.schema.json") as content:
21
+ return json.load(content)
@@ -0,0 +1,110 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "path": {
6
+ "type": "string"
7
+ },
8
+ "figures": {
9
+ "type": "array",
10
+ "items": {
11
+ "type": "object",
12
+ "properties": {
13
+ "figure_nr": {
14
+ "type": "integer"
15
+ },
16
+ "page_nr": {
17
+ "type": "integer"
18
+ },
19
+ "title": {
20
+ "type": "string"
21
+ },
22
+ "description": {
23
+ "type": "string"
24
+ },
25
+ "table": {
26
+ "anyOf": [
27
+ {
28
+ "type": "null"
29
+ },
30
+ {
31
+ "type": "array",
32
+ "items": {
33
+ "type": "array",
34
+ "items": {
35
+ "type": "object",
36
+ "properties": {
37
+ "text": {
38
+ "type": "string"
39
+ },
40
+ "tables": {
41
+ "type": "array",
42
+ "items": {
43
+ "type": "array",
44
+ "items": {
45
+ "type": "array",
46
+ "items": {
47
+ "type": "object",
48
+ "properties": {
49
+ "text": {
50
+ "type": "string"
51
+ },
52
+ "tables": {
53
+ "type": "array",
54
+ "items": {
55
+ "type": "array",
56
+ "items": {
57
+ "type": "array",
58
+ "items": {
59
+ "type": "object",
60
+ "properties": {
61
+ "text": {
62
+ "type": "string"
63
+ },
64
+ "tables": {
65
+ "type": "array"
66
+ }
67
+ },
68
+ "required": [
69
+ "tables",
70
+ "text"
71
+ ]
72
+ }
73
+ }
74
+ }
75
+ }
76
+ },
77
+ "required": [
78
+ "tables",
79
+ "text"
80
+ ]
81
+ }
82
+ }
83
+ }
84
+ }
85
+ },
86
+ "required": [
87
+ "tables",
88
+ "text"
89
+ ]
90
+ }
91
+ }
92
+ }
93
+ ]
94
+ }
95
+ },
96
+ "required": [
97
+ "description",
98
+ "figure_nr",
99
+ "page_nr",
100
+ "table",
101
+ "title"
102
+ ]
103
+ }
104
+ }
105
+ },
106
+ "required": [
107
+ "figures",
108
+ "path"
109
+ ]
110
+ }