struct-frame 0.0.25__py3-none-any.whl → 0.0.28__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.

Potentially problematic release.


This version of struct-frame might be problematic. Click here for more details.

struct_frame/__init__.py CHANGED
@@ -3,8 +3,9 @@ from .base import version, NamingStyleC, CamelToSnakeCase, pascalCase
3
3
  from .c_gen import FileCGen
4
4
  from .ts_gen import FileTsGen
5
5
  from .py_gen import FilePyGen
6
+ from .gql_gen import FileGqlGen
6
7
 
7
8
  from .generate import main
8
9
 
9
- __all__ = ["main", "FileCGen", "FileTsGen", "FilePyGen", "version",
10
+ __all__ = ["main", "FileCGen", "FileTsGen", "FilePyGen", "FileGqlGen", "version",
10
11
  "NamingStyleC", "CamelToSnakeCase", "pascalCase"]
struct_frame/c_gen.py CHANGED
@@ -17,6 +17,7 @@ c_types = {"uint8": "uint8_t",
17
17
  "double": "double",
18
18
  "uint64": 'uint64_t',
19
19
  "int64": 'int64_t',
20
+ "string": "char", # Add string type support
20
21
  }
21
22
 
22
23
 
@@ -60,6 +61,16 @@ class EnumCGen():
60
61
  result += ' %s;\n' % (enumName)
61
62
 
62
63
  result += 'typedef uint8_t %s_t;' % (enumName)
64
+
65
+ # Add module-prefixed enum constants for compatibility
66
+ result += '\n\n/* Enum constants with module prefix */\n'
67
+ module_prefix = CamelToSnakeCase(field.package).upper()
68
+ for d in field.data:
69
+ # Use the already correct enum constant name
70
+ enum_constant = f"{CamelToSnakeCase(field.name).upper()}_{StyleC.enum_entry(d)}"
71
+ module_constant = f"{module_prefix}_{enum_constant}"
72
+ result += f'#define {module_constant:<35} {enum_constant}\n'
73
+
63
74
  return result
64
75
 
65
76
 
@@ -67,18 +78,70 @@ class FieldCGen():
67
78
  @staticmethod
68
79
  def generate(field):
69
80
  result = ''
70
-
71
81
  var_name = field.name
72
82
  type_name = field.fieldType
83
+
84
+ # Handle basic type resolution
73
85
  if type_name in c_types:
74
- type_name = c_types[type_name]
86
+ base_type = c_types[type_name]
75
87
  else:
76
- type_name = '%s%s' % (pascalCase(field.package), type_name)
77
88
  if field.isEnum:
78
- type_name = '%s_t' % type_name
79
-
80
- result += ' %s %s%s;' % (type_name, var_name, "")
89
+ base_type = '%s%s_t' % (pascalCase(field.package), type_name)
90
+ else:
91
+ base_type = '%s%s' % (pascalCase(field.package), type_name)
92
+
93
+ # Handle arrays
94
+ if field.is_array:
95
+ if field.fieldType == "string":
96
+ # String arrays need both array size and individual string size
97
+ if field.size_option is not None:
98
+ # Fixed string array: size_option strings, each element_size chars
99
+ declaration = f"char {var_name}[{field.size_option}][{field.element_size}];"
100
+ comment = f" // Fixed string array: {field.size_option} strings, each max {field.element_size} chars"
101
+ elif field.max_size is not None:
102
+ # Variable string array: count byte + max_size strings of element_size chars each
103
+ declaration = f"struct {{ uint8_t count; char data[{field.max_size}][{field.element_size}]; }} {var_name};"
104
+ comment = f" // Variable string array: up to {field.max_size} strings, each max {field.element_size} chars"
105
+ else:
106
+ declaration = f"char {var_name}[1][1];" # Fallback
107
+ comment = " // String array (error in size specification)"
108
+ else:
109
+ # Non-string arrays
110
+ if field.size_option is not None:
111
+ # Fixed array: always exact size
112
+ declaration = f"{base_type} {var_name}[{field.size_option}];"
113
+ comment = f" // Fixed array: always {field.size_option} elements"
114
+ elif field.max_size is not None:
115
+ # Variable array: count byte + max elements
116
+ declaration = f"struct {{ uint8_t count; {base_type} data[{field.max_size}]; }} {var_name};"
117
+ comment = f" // Variable array: up to {field.max_size} elements"
118
+ else:
119
+ declaration = f"{base_type} {var_name}[1];" # Fallback
120
+ comment = " // Array (error in size specification)"
121
+
122
+ result += f" {declaration}{comment}"
123
+
124
+ # Handle regular strings
125
+ elif field.fieldType == "string":
126
+ if field.size_option is not None:
127
+ # Fixed string: exactly size_option characters
128
+ declaration = f"char {var_name}[{field.size_option}];"
129
+ comment = f" // Fixed string: exactly {field.size_option} chars"
130
+ elif field.max_size is not None:
131
+ # Variable string: length byte + max characters
132
+ declaration = f"struct {{ uint8_t length; char data[{field.max_size}]; }} {var_name};"
133
+ comment = f" // Variable string: up to {field.max_size} chars"
134
+ else:
135
+ declaration = f"char {var_name}[1];" # Fallback
136
+ comment = " // String (error in size specification)"
137
+
138
+ result += f" {declaration}{comment}"
139
+
140
+ # Handle regular fields
141
+ else:
142
+ result += f" {base_type} {var_name};"
81
143
 
144
+ # Add leading comments
82
145
  leading_comment = field.comments
83
146
  if leading_comment:
84
147
  for c in leading_comment:
@@ -178,12 +241,12 @@ class FileCGen():
178
241
  # yield '\n'
179
242
 
180
243
  if package.messages:
181
- yield 'uint8_t get_message_length(uint8_t msg_id){\n switch (msg_id)\n {\n'
244
+ yield 'bool get_message_length(size_t msg_id, size_t* size){\n switch (msg_id)\n {\n'
182
245
  for key, msg in package.sortedMessages().items():
183
246
  name = '%s_%s' % (CamelToSnakeCase(
184
247
  msg.package).upper(), CamelToSnakeCase(msg.name).upper())
185
248
  if msg.id:
186
- yield ' case %s_MSG_ID: return %s_MAX_SIZE;\n' % (name, name)
249
+ yield ' case %s_MSG_ID: *size = %s_MAX_SIZE; return true;\n' % (name, name)
187
250
 
188
- yield ' default: break;\n } return 0;\n}'
251
+ yield ' default: break;\n } return false;\n}'
189
252
  yield '\n'
struct_frame/generate.py CHANGED
@@ -7,8 +7,10 @@ import shutil
7
7
  from struct_frame import FileCGen
8
8
  from struct_frame import FileTsGen
9
9
  from struct_frame import FilePyGen
10
+ from struct_frame import FileGqlGen
10
11
  from proto_schema_parser.parser import Parser
11
12
  from proto_schema_parser import ast
13
+ from proto_schema_parser.ast import FieldCardinality
12
14
 
13
15
  import argparse
14
16
 
@@ -26,7 +28,8 @@ default_types = {
26
28
  "float": {"size": 4},
27
29
  "double": {"size": 8},
28
30
  "int64": {"size": 8},
29
- "uint64": {"size": 8}
31
+ "uint64": {"size": 8},
32
+ "string": {"size": 4} # Variable length, estimated size for length prefix
30
33
  }
31
34
 
32
35
 
@@ -79,14 +82,78 @@ class Field:
79
82
  self.comments = comments
80
83
  self.package = package
81
84
  self.isEnum = False
85
+ self.flatten = False
86
+ self.is_array = False
87
+ self.size_option = None # Fixed size using [size=X]
88
+ self.max_size = None # Variable size using [max_size=X]
89
+ # Element size for repeated string arrays [element_size=X]
90
+ self.element_size = None
82
91
 
83
92
  def parse(self, field):
84
93
  self.name = field.name
85
94
  self.fieldType = field.type
95
+
96
+ # Check if this is a repeated field (array)
97
+ if hasattr(field, 'cardinality') and field.cardinality == FieldCardinality.REPEATED:
98
+ self.is_array = True
99
+
86
100
  if self.fieldType in default_types:
87
101
  self.isDefaultType = True
88
102
  self.size = default_types[self.fieldType]["size"]
89
103
  self.validated = True
104
+
105
+ try:
106
+ if hasattr(field, 'options') and field.options:
107
+ # options is typically a list of ast.Option
108
+ for opt in field.options:
109
+ oname = getattr(opt, 'name', None)
110
+ ovalue = getattr(opt, 'value', None)
111
+ if not oname:
112
+ continue
113
+ lname = str(oname).strip()
114
+ # Support unqualified and a couple of qualified names
115
+ if lname in ('flatten', '(sf.flatten)', '(struct_frame.flatten)'):
116
+ sval = str(ovalue).strip().lower()
117
+ if sval in ('true', '1', 'yes', 'on') or ovalue is True:
118
+ self.flatten = True
119
+ elif lname in ('size', '(sf.size)', '(struct_frame.size)'):
120
+ # Fixed size for arrays or strings
121
+ try:
122
+ self.size_option = int(ovalue)
123
+ if self.size_option <= 0 or self.size_option > 255:
124
+ print(
125
+ f"Invalid size {self.size_option} for field {self.name}, must be 1-255")
126
+ return False
127
+ except (ValueError, TypeError):
128
+ print(
129
+ f"Invalid size value {ovalue} for field {self.name}, must be an integer")
130
+ return False
131
+ elif lname in ('max_size', '(sf.max_size)', '(struct_frame.max_size)'):
132
+ # Variable size for arrays or strings
133
+ try:
134
+ self.max_size = int(ovalue)
135
+ if self.max_size <= 0 or self.max_size > 255:
136
+ print(
137
+ f"Invalid max_size {self.max_size} for field {self.name}, must be 1-255")
138
+ return False
139
+ except (ValueError, TypeError):
140
+ print(
141
+ f"Invalid max_size value {ovalue} for field {self.name}, must be an integer")
142
+ return False
143
+ elif lname in ('element_size', '(sf.element_size)', '(struct_frame.element_size)'):
144
+ # Individual element size for repeated string arrays
145
+ try:
146
+ self.element_size = int(ovalue)
147
+ if self.element_size <= 0 or self.element_size > 255:
148
+ print(
149
+ f"Invalid element_size {self.element_size} for field {self.name}, must be 1-255")
150
+ return False
151
+ except (ValueError, TypeError):
152
+ print(
153
+ f"Invalid element_size value {ovalue} for field {self.name}, must be an integer")
154
+ return False
155
+ except Exception:
156
+ pass
90
157
  return True
91
158
 
92
159
  def validate(self, currentPackage, packages):
@@ -99,8 +166,8 @@ class Field:
99
166
  if ret:
100
167
  if ret.validate(currentPackage, packages):
101
168
  self.isEnum = ret.isEnum
102
- self.validate = True
103
- self.size = ret.size
169
+ self.validated = True
170
+ base_size = ret.size
104
171
  else:
105
172
  print(
106
173
  f"Failed to validate Field: {self.name} of Type: {self.fieldType} in Package: {currentPackage.name}")
@@ -109,6 +176,77 @@ class Field:
109
176
  print(
110
177
  f"Failed to find Field: {self.name} of Type: {self.fieldType} in Package: {currentPackage.name}")
111
178
  return False
179
+ else:
180
+ base_size = self.size
181
+
182
+ # Calculate size for arrays and strings
183
+ if self.is_array:
184
+ if self.fieldType == "string":
185
+ # String arrays need both array size AND individual element size
186
+ if self.element_size is None:
187
+ print(
188
+ f"String array field {self.name} missing required element_size option")
189
+ return False
190
+
191
+ if self.size_option is not None:
192
+ # Fixed string array: size_option strings, each element_size bytes
193
+ self.size = self.size_option * self.element_size
194
+ elif self.max_size is not None:
195
+ # Variable string array: 1 byte count + max_size strings of element_size bytes each
196
+ self.size = 1 + (self.max_size * self.element_size)
197
+ else:
198
+ print(
199
+ f"String array field {self.name} missing required size or max_size option")
200
+ return False
201
+ else:
202
+ # Non-string arrays
203
+ if self.size_option is not None:
204
+ # Fixed array: always full, no count byte needed
205
+ self.size = base_size * self.size_option
206
+ elif self.max_size is not None:
207
+ # Variable array: 1 byte for count + max space
208
+ self.size = 1 + (base_size * self.max_size)
209
+ else:
210
+ print(
211
+ f"Array field {self.name} missing required size or max_size option")
212
+ return False
213
+ elif self.fieldType == "string":
214
+ if self.size_option is not None:
215
+ # Fixed string: exactly size_option characters
216
+ self.size = self.size_option
217
+ elif self.max_size is not None:
218
+ # Variable string: 1 byte length + max characters
219
+ self.size = 1 + self.max_size
220
+ else:
221
+ print(
222
+ f"String field {self.name} missing required size or max_size option")
223
+ return False
224
+ else:
225
+ self.size = base_size
226
+
227
+ # Debug output
228
+ array_info = ""
229
+ if self.is_array:
230
+ if self.fieldType == "string":
231
+ # String arrays show both array size and individual element size
232
+ if self.size_option is not None:
233
+ array_info = f", fixed_string_array size={self.size_option}, element_size={self.element_size}"
234
+ elif self.max_size is not None:
235
+ array_info = f", bounded_string_array max_size={self.max_size}, element_size={self.element_size}"
236
+ else:
237
+ # Regular arrays
238
+ if self.size_option is not None:
239
+ array_info = f", fixed_array size={self.size_option}"
240
+ elif self.max_size is not None:
241
+ array_info = f", bounded_array max_size={self.max_size}"
242
+ elif self.fieldType == "string":
243
+ # Regular strings
244
+ if self.size_option is not None:
245
+ array_info = f", fixed_string size={self.size_option}"
246
+ elif self.max_size is not None:
247
+ array_info = f", variable_string max_size={self.max_size}"
248
+ print(
249
+ f" Field {self.name}: type={self.fieldType}, is_array={self.is_array}{array_info}, calculated_size={self.size}")
112
250
 
113
251
  return True
114
252
 
@@ -116,8 +254,21 @@ class Field:
116
254
  output = ""
117
255
  for c in self.comments:
118
256
  output = output + c + "\n"
257
+ array_info = ""
258
+ if self.is_array:
259
+ if self.size_option is not None:
260
+ array_info = f", Array[size={self.size_option}]"
261
+ elif self.max_size is not None:
262
+ array_info = f", Array[max_size={self.max_size}]"
263
+ else:
264
+ array_info = ", Array[no size specified]"
265
+ elif self.fieldType == "string":
266
+ if self.size_option is not None:
267
+ array_info = f", String[size={self.size_option}]"
268
+ elif self.max_size is not None:
269
+ array_info = f", String[max_size={self.max_size}]"
119
270
  output = output + \
120
- f"Field: {self.name}, Type:{self.fieldType}, Size:{self.size}"
271
+ f"Field: {self.name}, Type:{self.fieldType}, Size:{self.size}{array_info}"
121
272
  return output
122
273
 
123
274
 
@@ -166,6 +317,44 @@ class Message:
166
317
  return False
167
318
  self.size = self.size + value.size
168
319
 
320
+ # Flatten collision detection: if a field is marked as flatten and is a message,
321
+ # ensure none of the child field names collide with fields in this message.
322
+ parent_field_names = set(self.fields.keys())
323
+ for key, value in self.fields.items():
324
+ if getattr(value, 'flatten', False):
325
+ # Only meaningful for non-default, non-enum message types
326
+ if value.isDefaultType or value.isEnum:
327
+ # Flatten has no effect on primitives/enums; skip
328
+ continue
329
+ child = currentPackage.findFieldType(value.fieldType)
330
+ if not child or getattr(child, 'isEnum', False) or not hasattr(child, 'fields'):
331
+ # Unknown or non-message type; skip
332
+ continue
333
+ for ck in child.fields.keys():
334
+ if ck in parent_field_names:
335
+ print(
336
+ f"Flatten collision in Message {self.name}: field '{key}.{ck}' collides with existing field '{ck}'.")
337
+ return False
338
+
339
+ # Array validation
340
+ for key, value in self.fields.items():
341
+ if value.is_array:
342
+ # All arrays must have size or max_size specified
343
+ if value.size_option is None and value.max_size is None:
344
+ print(
345
+ f"Array field {key} in Message {self.name}: must specify size or max_size option")
346
+ return False
347
+ elif value.fieldType == "string":
348
+ # Strings must have size or max_size specified
349
+ if value.size_option is None and value.max_size is None:
350
+ print(
351
+ f"String field {key} in Message {self.name}: must specify size or max_size option")
352
+ return False
353
+ elif value.max_size is not None or value.size_option is not None or value.element_size is not None:
354
+ print(
355
+ f"Field {key} in Message {self.name}: size/max_size/element_size options can only be used with repeated fields or strings")
356
+ return False
357
+
169
358
  self.validated = True
170
359
  return True
171
360
 
@@ -260,9 +449,12 @@ parser.add_argument('--debug', action='store_true')
260
449
  parser.add_argument('--build_c', action='store_true')
261
450
  parser.add_argument('--build_ts', action='store_true')
262
451
  parser.add_argument('--build_py', action='store_true')
263
- parser.add_argument('--c_path', nargs=1, type=str, default=['c/'])
264
- parser.add_argument('--ts_path', nargs=1, type=str, default=['ts/'])
265
- parser.add_argument('--py_path', nargs=1, type=str, default=['py/'])
452
+ parser.add_argument('--c_path', nargs=1, type=str, default=['generated/c/'])
453
+ parser.add_argument('--ts_path', nargs=1, type=str, default=['generated/ts/'])
454
+ parser.add_argument('--py_path', nargs=1, type=str, default=['generated/py/'])
455
+ parser.add_argument('--build_gql', action='store_true')
456
+ parser.add_argument('--gql_path', nargs=1, type=str,
457
+ default=['generated/gql/'])
266
458
 
267
459
 
268
460
  def parseFile(filename):
@@ -350,15 +542,21 @@ def main():
350
542
  args = parser.parse_args()
351
543
  parseFile(args.filename)
352
544
 
353
- if (not args.build_c and not args.build_ts and not args.build_py):
545
+ if (not args.build_c and not args.build_ts and not args.build_py and not args.build_gql):
354
546
  print("Select at least one build argument")
355
547
  return
356
548
 
549
+ valid = False
357
550
  try:
358
- validatePackages()
551
+ valid = validatePackages()
359
552
  except RecursionError as err:
360
553
  print(
361
554
  f'Recursion Error. Messages most likely have a cyclical dependancy. Check Message: {recErrCurrentMessage} and Field: {recErrCurrentField}')
555
+ return
556
+
557
+ if not valid:
558
+ print("Validation failed; aborting code generation.")
559
+ return
362
560
 
363
561
  files = {}
364
562
  if (args.build_c):
@@ -370,6 +568,12 @@ def main():
370
568
  if (args.build_py):
371
569
  files.update(generatePyFileStrings(args.py_path[0]))
372
570
 
571
+ if (args.build_gql):
572
+ for key, value in packages.items():
573
+ name = os.path.join(args.gql_path[0], value.name + '.graphql')
574
+ data = ''.join(FileGqlGen.generate(value))
575
+ files[name] = data
576
+
373
577
  for filename, filedata in files.items():
374
578
  dirname = os.path.dirname(filename)
375
579
  if dirname and not os.path.exists(dirname):
@@ -392,6 +596,8 @@ def main():
392
596
  shutil.copytree(os.path.join(dir_path, "boilerplate/py"),
393
597
  args.py_path[0], dirs_exist_ok=True)
394
598
 
599
+ # No boilerplate for GraphQL currently
600
+
395
601
  if args.debug:
396
602
  printPackages()
397
603
  print("Struct Frame successfully completed")
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env python3
2
+ # Simple GraphQL schema generator for struct-frame
3
+
4
+ from struct_frame import version, pascalCase, CamelToSnakeCase
5
+ import time
6
+
7
+ # Mapping from proto primitive types to GraphQL scalar types
8
+ gql_types = {
9
+ "uint8": "Int",
10
+ "int8": "Int",
11
+ "uint16": "Int",
12
+ "int16": "Int",
13
+ "uint32": "Int",
14
+ "int32": "Int",
15
+ "uint64": "Int", # Could be custom scalar if needed
16
+ "int64": "Int", # Could be custom scalar if needed
17
+ "bool": "Boolean",
18
+ "float": "Float",
19
+ "double": "Float",
20
+ "string": "String",
21
+ }
22
+
23
+
24
+ def _gql_enum_value_name(name: str) -> str:
25
+ # If already in ALL_CAPS (possibly with underscores) keep as is
26
+ if name.replace('_', '').isupper():
27
+ return name
28
+ return CamelToSnakeCase(name).upper()
29
+
30
+
31
+ def _clean_comment_line(c: str) -> str:
32
+ c = c.strip()
33
+ if c.startswith('#'):
34
+ c = c[1:].strip()
35
+ # Remove leading // once or twice
36
+ if c.startswith('//'):
37
+ c = c[2:].strip()
38
+ # If parser already kept leading markers inside line, remove repeated
39
+ if c.startswith('//'):
40
+ c = c[2:].strip()
41
+ return c
42
+
43
+
44
+ def _triple_quote_block(lines):
45
+ cleaned = [_clean_comment_line(l) for l in lines if _clean_comment_line(l)]
46
+ if not cleaned:
47
+ return None
48
+ return '"""\n' + '\n'.join(cleaned) + '\n"""'
49
+
50
+
51
+ def _single_quote_line(lines):
52
+ cleaned = [_clean_comment_line(l) for l in lines if _clean_comment_line(l)]
53
+ if not cleaned:
54
+ return None
55
+ # Join multi-line into one sentence for single-line description
56
+ return '"' + ' '.join(cleaned) + '"'
57
+
58
+
59
+ class EnumGqlGen:
60
+ @staticmethod
61
+ def generate(enum):
62
+ lines = []
63
+ if enum.comments:
64
+ desc = _triple_quote_block(enum.comments)
65
+ if desc:
66
+ lines.append(desc)
67
+ enum_name = f"{pascalCase(enum.package)}{enum.name}"
68
+ lines.append(f"enum {enum_name} {{")
69
+ for key, value in enum.data.items():
70
+ if value[1]:
71
+ desc = _single_quote_line(value[1])
72
+ if desc:
73
+ lines.append(f" {desc}")
74
+ lines.append(f" {_gql_enum_value_name(key)}")
75
+ lines.append("}\n")
76
+ return '\n'.join(lines)
77
+
78
+
79
+ class FieldGqlGen:
80
+ @staticmethod
81
+ def type_name(field):
82
+ t = field.fieldType
83
+ base_type = gql_types.get(t, f"{pascalCase(field.package)}{t}")
84
+
85
+ # Handle arrays
86
+ if getattr(field, 'is_array', False):
87
+ # Arrays in GraphQL are represented as [Type!]! for non-null arrays of non-null elements
88
+ # or [Type] for nullable arrays, etc. We'll use [Type!]! as the standard
89
+ return f"[{base_type}!]!"
90
+
91
+ return base_type
92
+
93
+ @staticmethod
94
+ def generate(field, name_override=None):
95
+ lines = []
96
+
97
+ # Generate clean comments with size information, preferring our generated descriptions over proto comments
98
+ if getattr(field, 'is_array', False):
99
+ # Array field - use our size descriptions
100
+ if getattr(field, 'size_option', None) is not None:
101
+ # Fixed array
102
+ if field.fieldType == "string":
103
+ comment_lines = [
104
+ f"Fixed string array: {field.size_option} strings, each {getattr(field, 'element_size', 'N/A')} chars"]
105
+ else:
106
+ comment_lines = [
107
+ f"Fixed array: always {field.size_option} elements"]
108
+ else:
109
+ # Variable array
110
+ if field.fieldType == "string":
111
+ comment_lines = [
112
+ f"Variable string array: up to {getattr(field, 'max_size', 'N/A')} strings, each max {getattr(field, 'element_size', 'N/A')} chars"]
113
+ else:
114
+ comment_lines = [
115
+ f"Variable array: up to {getattr(field, 'max_size', 'N/A')} elements"]
116
+ elif field.fieldType == "string":
117
+ # Non-array string field
118
+ if getattr(field, 'size_option', None) is not None:
119
+ comment_lines = [
120
+ f"Fixed string: exactly {field.size_option} characters"]
121
+ elif getattr(field, 'max_size', None) is not None:
122
+ comment_lines = [
123
+ f"Variable string: up to {field.max_size} characters"]
124
+ else:
125
+ comment_lines = field.comments[:] if field.comments else []
126
+ else:
127
+ # Regular field - use original comments
128
+ comment_lines = field.comments[:] if field.comments else []
129
+
130
+ if comment_lines:
131
+ desc = _single_quote_line(comment_lines)
132
+ if desc:
133
+ lines.append(f" {desc}")
134
+
135
+ fname = name_override if name_override else field.name
136
+ lines.append(f" {fname}: {FieldGqlGen.type_name(field)}")
137
+ return '\n'.join(lines)
138
+
139
+ @staticmethod
140
+ def generate_flattened_children(field, package, parent_msg):
141
+ # Expand a message-typed field into its child fields.
142
+ # If a child field name collides, raise an error and fail generation.
143
+ t = field.fieldType
144
+ child_msg = package.messages.get(t)
145
+ if not child_msg:
146
+ # Fallback to normal generation if unknown
147
+ return [FieldGqlGen.generate(field)]
148
+
149
+ out_lines = []
150
+ for ck, cf in child_msg.fields.items():
151
+ out_lines.append(FieldGqlGen.generate(cf, name_override=ck))
152
+ return out_lines
153
+
154
+
155
+ class MessageGqlGen:
156
+ @staticmethod
157
+ def generate(package, msg):
158
+ lines = []
159
+ if msg.comments:
160
+ desc = _triple_quote_block(msg.comments)
161
+ if desc:
162
+ lines.append(desc)
163
+ type_name = f"{pascalCase(msg.package)}{msg.name}"
164
+ lines.append(f"type {type_name} {{")
165
+ if not msg.fields:
166
+ lines.append(" _empty: Boolean")
167
+ else:
168
+ for key, f in msg.fields.items():
169
+ if getattr(f, 'flatten', False) and f.fieldType not in gql_types:
170
+ lines.extend(
171
+ FieldGqlGen.generate_flattened_children(f, package, msg))
172
+ else:
173
+ lines.append(FieldGqlGen.generate(f))
174
+ lines.append("}\n")
175
+ return '\n'.join(lines)
176
+
177
+
178
+ class FileGqlGen:
179
+ @staticmethod
180
+ def generate(package):
181
+ # Multiline triple-quoted header block
182
+ yield f"# Automatically generated GraphQL schema\n# Generated by struct-frame {version} at {time.asctime()}\n\n"
183
+
184
+ first_block = True
185
+ # Enums
186
+ for _, enum in package.enums.items():
187
+ if not first_block:
188
+ yield '\n'
189
+ first_block = False
190
+ yield EnumGqlGen.generate(enum).rstrip() + '\n'
191
+
192
+ # Messages (object types)
193
+ for _, msg in package.sortedMessages().items():
194
+ if not first_block:
195
+ yield '\n'
196
+ first_block = False
197
+ yield MessageGqlGen.generate(package, msg).rstrip() + '\n'
198
+
199
+ # Root Query type
200
+ if package.messages:
201
+ if not first_block:
202
+ yield '\n'
203
+ yield 'type Query {\n'
204
+ for _, msg in package.sortedMessages().items():
205
+ type_name = f"{pascalCase(msg.package)}{msg.name}"
206
+ yield f" {msg.name}: {type_name}\n"
207
+ yield '}\n'