xref: /aosp_15_r20/external/pigweed/pw_protobuf/py/pw_protobuf/options.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Options file parsing for proto generation."""
15
16from fnmatch import fnmatchcase
17from pathlib import Path
18import re
19
20from google.protobuf import text_format
21
22from pw_protobuf_codegen_protos.codegen_options_pb2 import CodegenOptions
23from pw_protobuf_protos.field_options_pb2 import FieldOptions
24
25_MULTI_LINE_COMMENT_RE = re.compile(r'/\*.*?\*/', flags=re.MULTILINE)
26_SINGLE_LINE_COMMENT_RE = re.compile(r'//.*?$', flags=re.MULTILINE)
27_SHELL_STYLE_COMMENT_RE = re.compile(r'#.*?$', flags=re.MULTILINE)
28
29# A list of (proto field path, CodegenOptions) tuples.
30ParsedOptions = list[tuple[str, CodegenOptions]]
31
32
33def load_options_from(options: ParsedOptions, options_file_name: Path):
34    """Loads a single options file for the given .proto"""
35    with open(options_file_name) as options_file:
36        # Read the options file and strip all styles of comments before parsing.
37        options_data = options_file.read()
38        options_data = _MULTI_LINE_COMMENT_RE.sub('', options_data)
39        options_data = _SINGLE_LINE_COMMENT_RE.sub('', options_data)
40        options_data = _SHELL_STYLE_COMMENT_RE.sub('', options_data)
41
42        for line in options_data.split('\n'):
43            parts = line.strip().split(None, 1)
44            if len(parts) < 2:
45                continue
46
47            # Parse as a name glob followed by a protobuf text format.
48            try:
49                opts = CodegenOptions()
50                text_format.Merge(parts[1], opts)
51                options.append((parts[0], opts))
52            except:  # pylint: disable=bare-except
53                continue
54
55
56def _load_options_with_suffix(
57    include_paths: list[Path],
58    proto_file_name: Path,
59    options_files: list[Path],
60    suffix: str,
61) -> ParsedOptions:
62    """Loads the options for the given .proto file."""
63    options: ParsedOptions = []
64
65    # First load from any files that were directly specified.
66    for options_file in options_files:
67        if (
68            options_file.name == proto_file_name.with_suffix(suffix).name
69            and options_file.exists()
70        ):
71            load_options_from(options, options_file)
72
73    # Then search specified search paths for options files.
74    for include_path in include_paths:
75        options_file_name = include_path / proto_file_name.with_suffix(suffix)
76        if options_file_name.exists():
77            load_options_from(options, options_file_name)
78
79    return options
80
81
82def load_options(
83    include_paths: list[Path],
84    proto_file_name: Path,
85    options_files: list[Path],
86    allow_generic_options_extension: bool = True,
87) -> ParsedOptions:
88    # Try to load pwpb_options first. If they exist, ignore regular options.
89    options = _load_options_with_suffix(
90        include_paths, proto_file_name, options_files, ".pwpb_options"
91    )
92    if allow_generic_options_extension and not options:
93        options = _load_options_with_suffix(
94            include_paths, proto_file_name, options_files, ".options"
95        )
96
97    return options
98
99
100def match_options(name: str, options: ParsedOptions) -> CodegenOptions:
101    """Return the matching options for a name."""
102    matched = CodegenOptions()
103    for name_glob, mask_options in options:
104        if fnmatchcase(name, name_glob):
105            matched.MergeFrom(mask_options)
106
107    return matched
108
109
110def create_from_field_options(
111    field_options: FieldOptions,
112) -> CodegenOptions:
113    """Create a CodegenOptions from a FieldOptions."""
114    codegen_options = CodegenOptions()
115
116    if field_options.HasField('max_count'):
117        codegen_options.max_count = field_options.max_count
118
119    if field_options.HasField('max_size'):
120        codegen_options.max_size = field_options.max_size
121
122    return codegen_options
123
124
125def merge_field_and_codegen_options(
126    field_options: CodegenOptions, codegen_options: CodegenOptions
127) -> CodegenOptions:
128    """Merge inline field_options and options file codegen_options."""
129    # The field options specify protocol-level requirements. Therefore, any
130    # codegen options should not violate those protocol-level requirements.
131    if field_options.max_count > 0 and codegen_options.max_count > 0:
132        assert field_options.max_count == codegen_options.max_count
133
134    if field_options.max_size > 0 and codegen_options.max_size > 0:
135        assert field_options.max_size == codegen_options.max_size
136
137    merged_options = CodegenOptions()
138    merged_options.CopyFrom(field_options)
139    merged_options.MergeFrom(codegen_options)
140
141    return merged_options
142