@dforge-core/dforge-mcp 0.2.44 → 0.2.46-next

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +3 -2
  3. package/dist/server.js +109 -84
  4. package/package.json +48 -46
  5. package/resources/docs/conventions.md +17 -14
  6. package/resources/docs/dsl-reference.md +15 -5
  7. package/resources/schemas/entity.schema.json +1 -1
  8. package/resources/schemas/registers.schema.json +8 -3
  9. package/scripts/install-skills.mjs +1 -0
  10. package/skills/dforge-1c-import/SKILL.md +374 -0
  11. package/skills/dforge-1c-import/references/environment.md +97 -0
  12. package/skills/dforge-1c-import/references/loading.md +161 -0
  13. package/skills/dforge-1c-import/references/measurements.md +210 -0
  14. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  15. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  16. package/skills/dforge-1c-import/scripts/README.md +110 -0
  17. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  18. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  19. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  20. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  21. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  22. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  23. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  24. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  25. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  26. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  27. package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
  28. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  29. package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
  30. package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
  31. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  32. package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
  33. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  34. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  35. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  36. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  37. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  38. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  39. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  40. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  41. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  42. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  43. package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
  44. package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
  45. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  46. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  47. package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
  48. package/skills/dforge-1c-import/scripts/lib/peek.py +70 -0
  49. package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
  50. package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
  51. package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
  52. package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
  53. package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
  54. package/skills/dforge-1c-import/scripts/lib/recorders.py +94 -0
  55. package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
  56. package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
  57. package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
  58. package/skills/dforge-1c-import/scripts/lib/seedstate.py +488 -0
  59. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  60. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  61. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  62. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  63. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  64. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  65. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  66. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  67. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  68. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  69. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  70. package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
  71. package/skills/dforge-mcp-author/SKILL.md +8 -2
  72. package/skills/dforge-mcp-author/examples/column-security/entities/product.json +1 -1
  73. package/skills/dforge-mcp-author/examples/column-security/manifest.json +0 -1
  74. package/skills/dforge-mcp-author/examples/column-security/security/roles.json +2 -2
  75. package/skills/dforge-mcp-author/references/action-dsl.md +20 -7
  76. package/skills/dforge-mcp-author/references/conventions.md +7 -1
  77. package/skills/dforge-mcp-author/references/field-types.md +3 -2
  78. package/skills/dforge-mcp-author/references/formulas.md +5 -1
  79. package/skills/dforge-mcp-author/references/print-templates.md +5 -2
  80. package/skills/dforge-mcp-author/references/queries.md +1 -1
  81. package/skills/dforge-mcp-author/references/registers.md +18 -3
  82. package/skills/dforge-mcp-author/references/reports.md +4 -1
  83. package/skills/dforge-mcp-author/references/schema-import.md +1 -1
  84. package/skills/dforge-mcp-author/references/security.md +1 -1
  85. package/skills/dforge-mcp-author/references/translations.md +38 -15
  86. package/skills/dforge-mcp-author/references/validation-checklist.md +2 -3
  87. package/skills/dforge-module-build/SKILL.md +10 -1
  88. package/skills/dforge-module-ship/SKILL.md +31 -3
  89. package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +0 -35
@@ -0,0 +1,1432 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Emit dForge module packages from a compact field spec.
5
+
6
+ Hand-writing entity JSON for 190-odd ported objects would be transcription work with a
7
+ transcription error rate, so the shape of a dForge module is encoded once here and each
8
+ entity is declared as a list of fields. The generated files are the deliverable; edit
9
+ the spec and regenerate rather than patching the JSON.
10
+
11
+ Field spec: (name, type, description[, mods])
12
+
13
+ varchar:N text int:N whole number, numeric(N,0)
14
+ text textarea num:P,S numeric(P,S)
15
+ bool checkbox money numeric(18,2), currency
16
+ date date datetime timestamptz
17
+ user user picker drop:a=A|b dropdown
18
+ drop1c:Enum a dropdown whose options are read from that 1C enum
19
+ ref:entity FK + Reference pair (qualified codes allowed: ref:masterdata.product)
20
+ rawref an unresolvable 1C reference: the GUID, with no link (see autospec.py)
21
+
22
+ mods: "req" mandatory, "ro" not editable
23
+
24
+ python tools/genmodule.py # writes modules/masterdata, fleet and shipping
25
+ """
26
+ from __future__ import print_function
27
+
28
+ import codecs
29
+ import collections
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ import shutil
35
+
36
+ import paths
37
+
38
+ ROOT = paths.ROOT
39
+ MODULES = paths.MODULES
40
+
41
+ # 1C's own key is `_IDRRef binary(16)` - a GUID. dForge does not require the snowflake
42
+ # `cuid` for module entities (that is only the metadata schema's own rule), and its write
43
+ # pipeline already generates `Guid.NewGuid()` for a uuid identity column, so the ported
44
+ # entities keep the infobase's keys verbatim. That is what lets the data come across as a
45
+ # plain copy: no old-key -> new-key table, and every reference resolves without a lookup.
46
+ KEY_TYPE = "uuid"
47
+
48
+
49
+ # --------------------------------------------------------------------------- emitters
50
+
51
+ def named(spec, prefix):
52
+ """Does this type spec start with `prefix`? A spec handed through as a dict does not.
53
+
54
+ Every place that reads a spec by its keyword has to answer this, and the dict form would
55
+ otherwise crash in each of them with `AttributeError` on `startswith`.
56
+ """
57
+ return not isinstance(spec, dict) and spec.startswith(prefix)
58
+
59
+
60
+ def parse_type(spec):
61
+ """Type spec -> the dbDatatype / fieldTypeCd / extras a dForge column needs.
62
+
63
+ A dict is passed through verbatim. The vocabulary below covers what 1C's own types map
64
+ onto, and a register needs two columns 1C has no equivalent for - a generated one, and a
65
+ direction stamp that must be a real `int4` rather than this `int:` shorthand's numeric.
66
+ Inventing a keyword per case would grow the vocabulary for one caller each; handing a
67
+ column spec straight through says plainly that nothing here is being interpreted.
68
+ """
69
+ if isinstance(spec, dict):
70
+ return dict(spec)
71
+ if named(spec, "varchar:"):
72
+ return {"dbDatatype": "varchar", "fieldTypeCd": "text", "maxLen": int(spec.split(":")[1])}
73
+ if spec == "text":
74
+ return {"dbDatatype": "text", "fieldTypeCd": "textarea"}
75
+ if spec == "bool":
76
+ return {"dbDatatype": "bool", "fieldTypeCd": "checkbox", "formula": "false"}
77
+ if spec.startswith("int:"):
78
+ return {"dbDatatype": "numeric(%s,0)" % spec.split(":")[1], "fieldTypeCd": "number"}
79
+ if spec.startswith("num:"):
80
+ precision, scale = spec.split(":")[1].split(",")
81
+ return {"dbDatatype": "numeric(%s,%s)" % (precision, scale),
82
+ "fieldTypeCd": "number", "precision": int(scale)}
83
+ if spec == "money":
84
+ return {"dbDatatype": "numeric(18,2)", "fieldTypeCd": "currency", "precision": 2}
85
+ if spec == "date":
86
+ return {"dbDatatype": "date", "fieldTypeCd": "date"}
87
+ if spec == "datetime":
88
+ return {"dbDatatype": "timestamptz", "fieldTypeCd": "datetime"}
89
+ if spec == "user":
90
+ return {"dbDatatype": "int8", "fieldTypeCd": "user"}
91
+ if spec == "recorder":
92
+ # Which document wrote this record. 1C calls it the регистратор and gives a
93
+ # subordinate register one; dForge stamps the same thing as a jsonb entity link,
94
+ # which is what lets unposting find its own rows again.
95
+ return {"dbDatatype": "jsonb", "fieldTypeCd": "entitylink"}
96
+ if spec == "blob":
97
+ # 1C's serialization of a value. Nothing here can read it, so it carries no
98
+ # field type and no editor - the bytes are kept and nothing is claimed.
99
+ return {"dbDatatype": "bytea"}
100
+ if spec == "rawref":
101
+ # A 1C reference with nothing on this side to point at - a composite reference, or
102
+ # a target the port does not own. The GUID is kept so the migration does not lose
103
+ # it, but there is no link and no lookup, so it stays off the card.
104
+ #
105
+ # No `fieldTypeCd`: naming one that resolves to the text group while the column
106
+ # stores a uuid makes the platform bind filters and DML against the wrong type,
107
+ # and it says so at install. Letting it resolve the control from `dbDatatype` is
108
+ # the whole of the fix.
109
+ return {"dbDatatype": KEY_TYPE}
110
+ if spec.startswith("drop:"):
111
+ options = []
112
+ for item in spec[len("drop:"):].split("|"):
113
+ value, _, label = item.partition("=")
114
+ options.append({"value": value, "label": label or value})
115
+ width = max(len(o["value"]) for o in options)
116
+ return {"dbDatatype": "varchar", "fieldTypeCd": "dropdown",
117
+ "maxLen": max(width, 20), "params": {"options": options}}
118
+ raise ValueError("unknown field type %r" % spec)
119
+
120
+
121
+ def target_pk(entity):
122
+ """products.product -> product_id; carrier -> carrier_id"""
123
+ return entity.split(".")[-1] + "_id"
124
+
125
+
126
+ def emit_fields(fields, start=10, step=10, line_of=None, ref_names=None):
127
+ """Spec list -> (ordered column dict, FK reference dict).
128
+
129
+ `line_of` names the reference back to the owning document on a tabular-section
130
+ entity; that FK is then half of the composite key, as it is in 1C's `_VT` tables.
131
+ """
132
+ columns, references = {}, {}
133
+ ref_names = ref_names or {}
134
+ order = start
135
+ for item in fields:
136
+ name, spec, description = item[0], item[1], item[2]
137
+ mods = item[3] if len(item) > 3 else ""
138
+ # "calc" is a register resource: the engine owns it, the user never types it.
139
+ # "cache" is a column the platform WRITES - a totals cache's figures - so it is
140
+ # shown and not editable, and unlike "calc" it is not defaulted or made mandatory:
141
+ # a period with no movement of that resource has nothing in it.
142
+ if "cache" in mods:
143
+ flags = "V"
144
+ else:
145
+ flags = "VM" if "calc" in mods else ("VEM" if "req" in mods else "VE")
146
+ if spec == "rawref" or "hidden" in mods:
147
+ flags = flags.replace("V", "")
148
+
149
+ if named(spec, "ref:"):
150
+ entity = spec[len("ref:"):]
151
+ fk = name + "_id"
152
+ columns[fk] = {
153
+ "dbDatatype": KEY_TYPE,
154
+ "flags": "EM" if ("req" in mods or "notnull" in mods) else "E",
155
+ "orderNum": order,
156
+ "description": description + " ID",
157
+ }
158
+ # "notnull" is "req" that reaches the DDL as well: the column is mandatory in
159
+ # the metadata and NOT NULL in the table. Kept apart from "req" because adding
160
+ # NOT NULL to a column already full of rows is a migration that can fail, so it
161
+ # is said where it is true rather than applied to every required reference.
162
+ if "notnull" in mods:
163
+ columns[fk]["isNullable"] = False
164
+ if name == line_of:
165
+ columns[fk]["isPk"] = True
166
+ columns[fk]["isNullable"] = False
167
+ columns[fk] = _ordered_column(columns[fk])
168
+ columns[name] = {
169
+ "columnType": "R",
170
+ "fieldTypeCd": "lookup",
171
+ "flags": flags,
172
+ "orderNum": order + 5,
173
+ "description": description,
174
+ "link": {"entity": entity, "thisKey": fk, "otherKey": target_pk(entity)},
175
+ }
176
+ # The constraint's name is `FK_<Column>` unless the spec names it. An entity
177
+ # already installed carries the name its package was written with, and a
178
+ # renamed reference is a DROP and a re-ADD of the constraint - which on a large
179
+ # table is a validating scan for a cosmetic change.
180
+ references[ref_names.get(fk, "FK_%s" % _camel(fk))] = {
181
+ "from": {"field": fk},
182
+ "to": {"entity": entity, "field": target_pk(entity)},
183
+ }
184
+ else:
185
+ column = {"flags": flags, "orderNum": order, "description": description}
186
+ column.update(parse_type(spec))
187
+ if "req" in mods:
188
+ column["isNullable"] = False
189
+ if "calc" in mods:
190
+ column["formula"] = "0"
191
+ column["isNullable"] = False
192
+ columns[name] = _ordered_column(column)
193
+ order += step
194
+ return columns, references
195
+
196
+
197
+ COLUMN_KEY_ORDER = ["columnType", "dbDatatype", "baseDatatypeCd", "fieldTypeCd", "flags",
198
+ "isPk", "isIdentity", "isNullable", "maxLen", "precision", "formula",
199
+ "orderNum", "description", "link", "params"]
200
+
201
+
202
+ def _ordered_column(column):
203
+ return dict_in_order(column, COLUMN_KEY_ORDER)
204
+
205
+
206
+ def dict_in_order(source, order):
207
+ out = {}
208
+ for key in order:
209
+ if key in source:
210
+ out[key] = source[key]
211
+ for key in source:
212
+ if key not in out:
213
+ out[key] = source[key]
214
+ return out
215
+
216
+
217
+ def _camel(name):
218
+ return "".join(part.capitalize() for part in name.split("_"))
219
+
220
+
221
+ def plural(code):
222
+ """View codes are the entity code pluralised - haulage_class -> haulage_classes."""
223
+ if code.endswith(("s", "x", "ch", "sh")):
224
+ return code + "es"
225
+ if code.endswith("y") and not code.endswith(("ay", "ey", "oy", "uy")):
226
+ return code[:-1] + "ies"
227
+ return code + "s"
228
+
229
+
230
+ def placeholder_column(spec):
231
+ """`is_missing` — this row stands in for a reference that pointed at nothing.
232
+
233
+ 1C does not enforce referential integrity and dForge does, so a copy always arrives
234
+ carrying references whose target was deleted from the infobase years ago. Nulling
235
+ them would lose what the document actually said, and dropping the foreign key would
236
+ give up the integrity the port is here to gain. The migration inserts the missing row
237
+ instead and raises this flag, so the reference still resolves and the row says what
238
+ it is rather than pretending to be reference data.
239
+
240
+ Only an object that can be the far side of a reference gets it: not a tabular
241
+ section, and not a register.
242
+ """
243
+ if spec.get("lineOf") or spec.get("family") in ("InfoRg", "AccumRg"):
244
+ return {}
245
+ # Nothing is copied into a derived entity - a totals cache is built from the journal
246
+ # by the platform - so there is no row that could be standing in for one the infobase
247
+ # has and the load has not reached yet.
248
+ if spec.get("derived"):
249
+ return {}
250
+ return {"is_missing": {
251
+ "dbDatatype": "bool",
252
+ "fieldTypeCd": "checkbox",
253
+ "flags": "V",
254
+ "formula": "false",
255
+ "orderNum": 9990,
256
+ "description": "Missing from the infobase",
257
+ }}
258
+
259
+
260
+ def key_columns(code, spec):
261
+ """The entity's primary key, shaped like the 1C table it was ported from.
262
+
263
+ A catalog or document is keyed by its `_IDRRef`, so the column is a uuid the platform
264
+ fills with a fresh GUID for records created here and the migration fills with the
265
+ infobase's own. A tabular section is keyed by `(<owner>_IDRRef, _LineNo)`; the owner
266
+ half is the FK emitted from the spec, so only the line number is added here.
267
+
268
+ `key` names the columns of an entity that is keyed by its own values and has no
269
+ surrogate at all. A register's `totals` cache is the case: the platform requires its
270
+ PRIMARY KEY to be exactly (dimensions, period), so a minted key would not just be
271
+ redundant - install refuses the cache outright. Nothing is added here; the named
272
+ columns are marked in place by `mark_key`, because a `ref:` field's key is the `_id`
273
+ column beside it rather than the field's own name.
274
+ """
275
+ if spec.get("key"):
276
+ return {}
277
+ if spec.get("lineOf"):
278
+ return {"line_no": {
279
+ "dbDatatype": "int4",
280
+ "fieldTypeCd": "number",
281
+ "flags": "VM",
282
+ "isPk": True,
283
+ "isNullable": False,
284
+ "orderNum": 12,
285
+ "description": "Line no",
286
+ }}
287
+ return {code + "_id": {
288
+ "dbDatatype": KEY_TYPE,
289
+ "flags": "I",
290
+ "isPk": True,
291
+ "isIdentity": True,
292
+ "isNullable": False,
293
+ "orderNum": 1,
294
+ "description": code.replace("_", " ").capitalize() + " ID",
295
+ }}
296
+
297
+
298
+ def _readable(column):
299
+ """A value a caption can show: not a GUID, and not a surrogate integer key."""
300
+ if column.get("fieldTypeCd") == "user":
301
+ return True
302
+ return not (column.get("dbDatatype") in ("uuid", "cuid")
303
+ or (column.get("isPk") and column.get("dbDatatype") in ("int8", "bigint")))
304
+
305
+
306
+ def _first(codes, *patterns):
307
+ for pattern in patterns:
308
+ for found in codes:
309
+ if (found.lower().endswith(pattern[1:]) if pattern.startswith("*")
310
+ else found.lower() == pattern):
311
+ return found
312
+ return None
313
+
314
+
315
+ def caption(code, fields):
316
+ """The entity's toString, for one whose spec names none.
317
+
318
+ First what the platform would derive itself - a number with its date, a code and a
319
+ name, a name, a code - so the file says what the record will show instead of leaving
320
+ it to a warning on every validate (ToStringTemplate.Generate is the rule copied here).
321
+
322
+ Then what the platform cannot derive: a register record, or a settings row, has no
323
+ number, code or name. 1C lists such a record by what it is about and when, so that is
324
+ its caption here too: the first visible reference, or failing one the first readable
325
+ value, with the period or date after it. A raw GUID is never a caption.
326
+ """
327
+ visible = [(name, column) for name, column in fields.items()
328
+ if "V" in column.get("flags", "") and _readable(column)]
329
+ scalars = [name for name, column in visible if column.get("columnType") not in ("R", "S", "F")]
330
+ lookups = [name for name, column in visible if column.get("columnType") == "R"]
331
+
332
+ number = _first(scalars, "number", code + "_number", code + "_no")
333
+ date = _first(scalars, "date", code + "_date")
334
+ if number:
335
+ return "{%s} {%s}" % (number, date) if date else "{%s}" % number
336
+ name = _first(scalars, "display_name", "name", "full_name", "title", "subject",
337
+ "*_name", "*_title", "label", "description")
338
+ coded = _first(scalars, "code", code + "_code", code + "_cd")
339
+ if coded and name:
340
+ return u"{%s} — {%s}" % (coded, name)
341
+ if name or coded:
342
+ return "{%s}" % (name or coded)
343
+
344
+ when = _first(scalars, "period", "date", "doc_date", "*_date")
345
+ about = (lookups[0] if lookups else
346
+ next((n for n in scalars if n != when and fields[n].get("fieldTypeCd") != "checkbox"),
347
+ None))
348
+ parts = [p for p in (about, when) if p]
349
+ return " ".join("{%s}" % p for p in parts) or None
350
+
351
+
352
+ def mark_key(columns, key):
353
+ """Mark the named columns as the entity's own primary key.
354
+
355
+ A name is taken as `<name>_id` when the spec named a reference - the key is the FK,
356
+ not the lookup column beside it - and as written otherwise.
357
+ A name matching neither is an error rather than a column quietly left out of the key,
358
+ which would leave the cache keyed by less than the register demands.
359
+ """
360
+ for name in key:
361
+ column = name + "_id" if name + "_id" in columns else name
362
+ if column not in columns:
363
+ raise KeyError("key names %r, which this entity has no column for" % name)
364
+ columns[column]["isPk"] = True
365
+ columns[column]["isNullable"] = False
366
+ columns[column] = _ordered_column(columns[column])
367
+ return columns
368
+
369
+
370
+ def build_entity(code, spec):
371
+ entity = {"description": spec["desc"], "dbObject": code}
372
+ if spec.get("toString"):
373
+ entity["toString"] = spec["toString"]
374
+ elif spec.get("lineOf"):
375
+ # A tabular-section row reads as its document and its line. The platform would
376
+ # derive exactly this, and says so on every validate; written down, it is settled.
377
+ entity["toString"] = "{%s} #{line_no}" % spec["lineOf"]
378
+ # No "identity" trait: that one hard-codes a snowflake `cuid`, and these entities
379
+ # carry the infobase's GUID instead. See key_columns().
380
+ # An entity with no traits carries no key rather than an empty list: a platform-
381
+ # maintained cache is audited by nothing, and saying so with `[]` and with silence
382
+ # are the same thing to the installer.
383
+ if spec.get("traits", ["audit"]):
384
+ entity["traits"] = spec.get("traits", ["audit"])
385
+ if spec.get("numberSequence"):
386
+ entity["numberSequence"] = spec["numberSequence"]
387
+
388
+ columns, references = emit_fields(spec["fields"], line_of=spec.get("lineOf"),
389
+ ref_names=spec.get("refNames", {}))
390
+ columns.update(key_columns(code, spec))
391
+ columns.update(placeholder_column(spec))
392
+ if spec.get("key"):
393
+ mark_key(columns, spec["key"])
394
+
395
+ order = 10 * (len(spec["fields"]) + 2)
396
+ for name, child, label, thiskey, otherkey in spec.get("sets", []):
397
+ columns[name] = {
398
+ "columnType": "S",
399
+ "fieldTypeCd": "grid",
400
+ "flags": "VE",
401
+ "orderNum": order,
402
+ "description": label,
403
+ "link": {"entity": child, "thisKey": thiskey, "otherKey": otherkey},
404
+ }
405
+ order += 10
406
+
407
+ for name, column in spec.get("registry", {}).items():
408
+ column = dict(column)
409
+ column["orderNum"] = 900
410
+ columns[name] = _ordered_column(column)
411
+
412
+ # Written in column order rather than the order the spec happened to build them in,
413
+ # so a generated file reads like the card it produces.
414
+ entity["fields"] = collections.OrderedDict(
415
+ sorted(columns.items(), key=lambda kv: kv[1].get("orderNum", 0)))
416
+ if not entity.get("toString"):
417
+ found = caption(code, entity["fields"])
418
+ if found:
419
+ entity["toString"] = found
420
+ if spec.get("constraints"):
421
+ entity["constraints"] = spec["constraints"]
422
+ if spec.get("indexes"):
423
+ entity["indexes"] = spec["indexes"]
424
+ if references:
425
+ entity["references"] = references
426
+ return entity
427
+
428
+
429
+ def build_extension(module, target, fields, registry=None, description=None):
430
+ """Columns this module adds to another module's entity.
431
+
432
+ The column lives here rather than on the entity itself because the reference points
433
+ back at this module: the owning module already depends on us, so declaring it there
434
+ would make the two depend on each other and neither could install first.
435
+
436
+ `registry` adds posting columns, as an entity's own do: a bridge that posts the other
437
+ module's document to a register (the state kept in this module's extension table).
438
+ """
439
+ columns, references = emit_fields(fields, start=60)
440
+ for name, column in (registry or {}).items():
441
+ column = dict(column)
442
+ column["orderNum"] = 900
443
+ columns[name] = _ordered_column(column)
444
+ other = target.split(".")[0]
445
+ return dict_in_order({
446
+ "extends": target,
447
+ "dbObject": target.split(".")[-1],
448
+ "description": description or (
449
+ "%s extension for %s - added here, not in %s, because the reference points "
450
+ "back at %s." % (module.capitalize(), target, other, module)),
451
+ "fields": columns,
452
+ "references": references,
453
+ }, ["extends", "dbObject", "description", "fields", "references"])
454
+
455
+
456
+ # The columns 1C gives every object of its family. They identify the record, so they
457
+ # lead the card; everything the integrator added follows behind them.
458
+ IDENTITY = ["code", "name", "deletion_mark", "is_folder", "parent", "owner",
459
+ "doc_no", "doc_date", "posted", "period", "recorder", "line_no",
460
+ "record_kind", "is_active", "is_missing"]
461
+
462
+ CARD_GROUPS_UK = {"main": u"\u0417\u0430\u0433\u0430\u043b\u044c\u043d\u0435",
463
+ "details": u"\u0420\u0435\u043a\u0432\u0456\u0437\u0438\u0442\u0438"}
464
+
465
+
466
+ def card_fields(entity_spec):
467
+ """The columns a card shows, split into the identifying ones and the rest.
468
+
469
+ A hidden column is hidden on the card too: a raw GUID with nothing to point at has
470
+ nothing to show. A set is not here either - it becomes a tab of its own.
471
+ """
472
+ head, rest = [], []
473
+ for item in entity_spec["fields"]:
474
+ mods = item[3] if len(item) > 3 else ""
475
+ if item[1] == "rawref" or "hidden" in mods:
476
+ continue
477
+ (head if item[0] in IDENTITY else rest).append(item[0])
478
+ if placeholder_column(entity_spec):
479
+ head.append("is_missing")
480
+ return head, rest
481
+
482
+
483
+ def build_card_layout(code, entity_spec):
484
+ """One record's form: what identifies it up top, the rest behind tabs.
485
+
486
+ `Номенклатура` carries 81 attributes and eleven tabular sections, so the card that
487
+ just lists its columns in order is unreadable. 1C solves this with a tabbed form, and
488
+ so does this: the identifying columns stay visible, and everything else - the
489
+ remaining attributes, then one tab per tabular section - goes behind a tab bar.
490
+ """
491
+ if entity_spec.get("lineOf"):
492
+ return None
493
+ head, rest = card_fields(entity_spec)
494
+ sets = [item[0] for item in entity_spec.get("sets", [])]
495
+ if not head and not rest and not sets:
496
+ return None
497
+
498
+ sections = [{"type": "columnGroup", "code": "main", "label": "General",
499
+ "cols": 2, "columns": head}]
500
+ tabs = []
501
+ if rest:
502
+ sections.append({"type": "columnGroup", "code": "details",
503
+ "label": "Attributes", "cols": 2, "columns": rest})
504
+ if sets:
505
+ tabs.append({"type": "section", "code": "details"})
506
+ for name in sets:
507
+ tabs.append({"type": "set", "code": name})
508
+ if tabs:
509
+ sections.append({"type": "tabGroup", "code": "tabs", "tabs": tabs})
510
+
511
+ return {"entity": code, "isDefault": True, "layout": {"sections": sections}}
512
+
513
+
514
+ def build_translations(spec):
515
+ """The `uk` section of a spec -> a dForge translations/uk-UA.json.
516
+
517
+ Hidden FK labels are derived from their Reference sibling ("Перевізник" -> that plus
518
+ " ID"), so only the visible column needs translating by hand. Key columns are derived
519
+ the same way: an entity's own key reads "<entity> ID" and a tabular section's line
520
+ number is always the same word, so neither is worth a spec entry.
521
+ """
522
+ uk = spec["uk"]
523
+ entities = {}
524
+ for code, entity_uk in uk.get("entities", {}).items():
525
+ entity_spec = spec["entities"][code]
526
+ field_specs = {f[0]: f[1] for f in entity_spec["fields"]}
527
+ fields = {}
528
+ if entity_spec.get("lineOf"):
529
+ fields["line_no"] = {"label": "№ рядка"}
530
+ elif entity_spec.get("key"):
531
+ # Keyed by its own values: there is no surrogate column to label, and naming
532
+ # one makes `validate` report a translation for a field that does not exist.
533
+ pass
534
+ else:
535
+ fields[code + "_id"] = {"label": entity_uk["_"] + " ID"}
536
+ if placeholder_column(entity_spec):
537
+ fields["is_missing"] = {"label": u"Відсутній в інфобазі"}
538
+ for name, label in entity_uk.get("f", {}).items():
539
+ fields[name] = {"label": label}
540
+ if named(field_specs.get(name, ""), "ref:"):
541
+ fields[name + "_id"] = {"label": label + " ID"}
542
+ for name, options in entity_uk.get("o", {}).items():
543
+ fields.setdefault(name, {})["options"] = options
544
+ entry = {"label": entity_uk["_"], "desc": entity_uk.get("desc", entity_uk["_"])}
545
+ if fields:
546
+ entry["fields"] = fields
547
+ # A card's section heading is localized through its column group, under the
548
+ # entity that owns it.
549
+ layout = build_card_layout(code, entity_spec)
550
+ if layout:
551
+ groups = dict(
552
+ (section["code"], {"label": CARD_GROUPS_UK[section["code"]]})
553
+ for section in layout["layout"]["sections"]
554
+ if section["type"] == "columnGroup")
555
+ if groups:
556
+ entry["columnGroups"] = groups
557
+ entities[code] = entry
558
+
559
+ # An extension's columns belong to the other module's entity, so they are translated
560
+ # under its dotted code rather than under an entity of our own.
561
+ for target, extension_fields in spec.get("extends", {}).items():
562
+ translated = dict((column, {"label": label}) for column, label
563
+ in ((spec.get("extendUk") or {}).get(target) or {}).items())
564
+ for item in extension_fields:
565
+ column, kind, label = item[0], item[1], item[2]
566
+ ukrainian = item[3] if len(item) > 3 else None
567
+ if not ukrainian:
568
+ continue
569
+ translated[column] = {"label": ukrainian}
570
+ if named(kind, "ref:"):
571
+ translated[column + "_id"] = {"label": ukrainian + " ID"}
572
+ if translated:
573
+ entities[target] = {"fields": translated}
574
+
575
+ doc = {"entities": entities}
576
+ if uk.get("folder"):
577
+ doc["folders"] = {spec["code"]: {"label": uk["folder"]}}
578
+ if uk.get("views"):
579
+ doc["views"] = {code: {"label": label} for code, label in uk["views"].items()}
580
+ if uk.get("menu"):
581
+ menu = dict(uk["menu"])
582
+ doc["menus"] = {
583
+ "%s_menu" % spec["code"].replace("-", "_"): {
584
+ "label": menu.pop("_"),
585
+ "items": {code: {"label": label} for code, label in menu.items()},
586
+ }
587
+ }
588
+ if uk.get("roles"):
589
+ doc["roles"] = {code: {"label": label} for code, label in uk["roles"].items()}
590
+ if uk.get("settings"):
591
+ doc["settings"] = {code: {"label": label} for code, label in uk["settings"].items()}
592
+ return doc
593
+
594
+
595
+ def write_json(path, doc):
596
+ directory = os.path.dirname(path)
597
+ if not os.path.isdir(directory):
598
+ os.makedirs(directory)
599
+ with codecs.open(path, "w", "utf-8") as f:
600
+ f.write(json.dumps(doc, ensure_ascii=False, indent="\t"))
601
+ f.write("\n")
602
+
603
+
604
+ # Everything under a module directory is written from the spec, so a rename in the spec
605
+ # has to take the old file with it. Without this a renamed entity lingers as a stale
606
+ # JSON file that still validates and would still install.
607
+ OWNED = ["manifest.json", "settings.json", "registers.json",
608
+ os.path.join("entities"), os.path.join("ui"), os.path.join("deps"),
609
+ os.path.join("security"), os.path.join("translations"),
610
+ # Never written from the spec, only copied from the project's `authored/` tree -
611
+ # but owned all the same, so an action dropped there is dropped here too.
612
+ os.path.join("logic"), os.path.join("migrations"), os.path.join("print_templates")]
613
+
614
+
615
+ def clean_module(base):
616
+ for relative in OWNED:
617
+ path = os.path.join(base, relative)
618
+ if os.path.isdir(path):
619
+ shutil.rmtree(path)
620
+ elif os.path.isfile(path):
621
+ os.remove(path)
622
+
623
+
624
+ def menu_view_codes(items):
625
+ """Every data view a menu opens, however deep its groups nest."""
626
+ for item in items.values():
627
+ if item.get("dataViewCode"):
628
+ yield item["dataViewCode"]
629
+ for code in menu_view_codes(item.get("children") or {}):
630
+ yield code
631
+
632
+
633
+ def data_source(code, entity):
634
+ """A view's source over one entity, with the columns named when none would show.
635
+
636
+ A grid draws an entity's visible scalar columns, and an entity whose every column is
637
+ hidden - a register carrying only composite 1C references, which stay raw GUIDs - draws
638
+ the platform's empty state instead. Naming the columns is what the platform reads as the
639
+ author asking for them, visible or not, so such a view lists them rather than lying
640
+ empty.
641
+ """
642
+ source = {"entityCode": code, "level": 0}
643
+ fields = entity["fields"]
644
+ scalar = [name for name, column in fields.items() if column.get("columnType") != "S"]
645
+ if not any("V" in fields[name].get("flags", "") for name in scalar):
646
+ shown = [name for name in scalar if not fields[name].get("isPk")]
647
+ source["columns"] = shown or scalar
648
+ return source
649
+
650
+
651
+ def readable_lookups(roles, built, module):
652
+ """Each role, plus `S` on whatever a lookup it may fill in points at.
653
+
654
+ A lookup the user cannot read the target of is hidden from the form, and a required
655
+ one then makes the record unsaveable - for a role that was granted the right to write
656
+ it. Which entities a lookup reaches is a fact of the entity files, not a decision, so
657
+ it is not left to each role to repeat: a right the role already names is kept as it is.
658
+
659
+ **A target in THIS module is named bare, whatever the link calls it.** The generator
660
+ writes some same-module links qualified (`production.act_rework`) and some bare, and a
661
+ qualified name does not match the bare key the role already grants - so the read grant
662
+ was added anyway, as a SECOND key for the same entity. The installer resolves both and
663
+ the read-only one wins: 18 of the port's own documents across 7 modules had `U` taken
664
+ off the role that owns them, which is a posting that cannot post and a card that cannot
665
+ be saved. Normalising the name is the whole fix; the rule that a right already named is
666
+ kept then does the rest.
667
+ """
668
+ out = collections.OrderedDict()
669
+ prefix = module + "."
670
+ for code, role in roles.items():
671
+ rights = collections.OrderedDict(role.get("rights", {}))
672
+ reached = set()
673
+ for entity, granted in role.get("rights", {}).items():
674
+ if not set(granted) & set("IU") or entity not in built:
675
+ continue
676
+ for column in built[entity]["fields"].values():
677
+ if column.get("columnType") == "R" and column.get("link"):
678
+ target = column["link"]["entity"]
679
+ if target.startswith(prefix) and target[len(prefix):] in built:
680
+ target = target[len(prefix):]
681
+ reached.add(target)
682
+ for target in sorted(reached - set(rights)):
683
+ rights[target] = "S"
684
+ out[code] = dict(role, rights=rights)
685
+ return out
686
+
687
+
688
+ # A right on another module's entity is qualified by that module; these keys are not
689
+ # entities at all, and their own dots name no provider.
690
+ NON_ENTITY_RIGHTS = ("action:", "sp:", "report:")
691
+
692
+
693
+ def dependency_contracts(spec, built, dependencies):
694
+ """deps/<provider>.json for every manifest dependency: what this module consumes.
695
+
696
+ The platform refuses a dependency without one, and checks each against what it can
697
+ read off the package - reference links, FK blocks, `extends`, role grants and view
698
+ sources. So the contract is read off the very same places, from the entities as they
699
+ are written, rather than from the spec's shorthand: a contract derived from anything
700
+ else is a second opinion that can disagree with the files it describes.
701
+
702
+ Every cross-module touch lands on the provider's key (`ref:` points at `<entity>_id`),
703
+ so a contract names entities and their keys and no other columns. A touch that lands
704
+ elsewhere raises, because its storage class would have to be looked up in the provider
705
+ and nothing here does that yet.
706
+ """
707
+ own = spec["code"]
708
+ used = collections.defaultdict(lambda: collections.defaultdict(set))
709
+
710
+ def note(qualified, token, column=None):
711
+ module, _, entity = (qualified or "").partition(".")
712
+ if not entity or module == own:
713
+ return
714
+ used[module][entity].add(token)
715
+ if column and column != target_pk(qualified):
716
+ raise ValueError("%s: %s reads %s.%s, which is not its key - deps/%s.json would "
717
+ "have to type that column" % (own, token, qualified, column, module))
718
+
719
+ for key, entity in built.items():
720
+ if entity.get("extends"):
721
+ note(entity["extends"], "extends:" + entity["extends"])
722
+ for name, column in entity.get("fields", {}).items():
723
+ link = column.get("link")
724
+ if link:
725
+ note(link["entity"], "ref:%s.%s" % (key, name), link["otherKey"])
726
+ for reference in (entity.get("references") or {}).values():
727
+ note(reference["to"]["entity"], "ref:%s.%s" % (key, reference["from"]["field"]),
728
+ reference["to"]["field"])
729
+
730
+ for role, body in spec["roles"].items():
731
+ for right in body.get("rights", {}):
732
+ if not right.startswith(NON_ENTITY_RIGHTS):
733
+ note(right, "role:" + role)
734
+
735
+ # A register is declared over an entity, and the declaration is a row in the platform's
736
+ # register table pointing at `metadata.entity`. That is what the `metadata` floor in
737
+ # the manifest buys, so it is what that dependency's contract names.
738
+ # A turnover register keeps its movements in a journal rather than a state entity.
739
+ for register in (spec.get("registerDefs") or {}).values():
740
+ used["metadata"]["entity"].add("ref:%s" % (register.get("entity") or register["journal"]))
741
+
742
+ # What the project says a module uses that nothing in the package shows - a posting
743
+ # column bound to another module's register writes that module's journal, and neither
744
+ # the binding nor its register is a reference link a contract can be read off.
745
+ for qualified, token in DECLARED_USES.get(own, ()):
746
+ note(qualified, token)
747
+
748
+ # A system module required for what the platform does at install - running a
749
+ # migration - is a version gate, not a consumption: its contract names no entity.
750
+ gates = set(DECLARED_MODULE.get(own, {}).get("requires", {}))
751
+ for gate in gates:
752
+ used.setdefault(gate, collections.defaultdict(set))
753
+
754
+ unused = sorted(set(dependencies) - set(used))
755
+ if unused:
756
+ raise ValueError("%s depends on %s but uses nothing of it that a contract can "
757
+ "name - drop the dependency or say what it is for"
758
+ % (own, ", ".join(unused)))
759
+ undeclared = sorted(set(used) - set(dependencies))
760
+ if undeclared:
761
+ raise ValueError("%s uses %s without depending on it"
762
+ % (own, ", ".join("%s (%s)" % (m, ", ".join(sorted(used[m])))
763
+ for m in undeclared)))
764
+ return dict(
765
+ (module, {
766
+ "module": module,
767
+ "version": dependencies[module],
768
+ "entities": collections.OrderedDict(
769
+ (entity, {"pk": entity + "_id", "use": sorted(tokens)})
770
+ for entity, tokens in sorted(used[module].items())),
771
+ })
772
+ for module in sorted(used))
773
+
774
+
775
+ # What each register shape first became understood in. A module asks for the highest floor
776
+ # its own declarations reach, because asking for less than a declaration needs is a manifest
777
+ # that lies: it installs against a tenant that would refuse the register, and the refusal
778
+ # arrives as a register error rather than as the version mismatch it is.
779
+ #
780
+ # There is one floor now, and the reason is worth keeping. These were seven versions -
781
+ # 1.13.0 through 1.20.0, one per shape as each landed - and the platform branch **squashed
782
+ # them into 1.13.x**, one minor per branch. So a module generated against the old table asked
783
+ # for `metadata >= 1.17.0` and was refused by a tenant carrying every one of those features
784
+ # under 1.13.2.
785
+ #
786
+ # It moved once more, and not because a shape changed: merging main back into the register
787
+ # branch pushed the register migration from 1.13.0 to 1.14.0, so the whole set is understood
788
+ # at 1.14.0 and a manifest asking for 1.13.0 now understates what it needs. Read the floor
789
+ # from the platform's own `system-modules/metadata/manifest.json` when in doubt - it is the
790
+ # only place that cannot drift.
791
+ #
792
+ # The list stays a list: the next feature to land gets a row, and the mechanism below then
793
+ # does something again. Reading it as dead weight and deleting it is how the lesson gets
794
+ # lost.
795
+ REGISTER_FLOOR = [
796
+ ("1.14.0", lambda d: True),
797
+ # `recordChangesOnly` / `absentAs`, on the dForge-core branch 261006-binding-literal-types.
798
+ ("1.17.1", lambda d: bool(d.get("recordChangesOnly"))),
799
+ ]
800
+
801
+
802
+ def metadata_floor(register_defs):
803
+ """The lowest platform metadata version that understands every register declared."""
804
+ floor = "1.14.0"
805
+ for definition in register_defs.values():
806
+ for version, applies in REGISTER_FLOOR:
807
+ if applies(definition) and _version(version) > _version(floor):
808
+ floor = version
809
+ return floor
810
+
811
+
812
+ def _version(text):
813
+ return tuple(int(part) for part in text.split("."))
814
+
815
+
816
+ def emit_module(spec):
817
+ base = os.path.join(MODULES, spec["code"])
818
+ clean_module(base)
819
+ entities = spec["entities"]
820
+
821
+ # The metadata floor rides in the dependency map beside the module ones — the
822
+ # manifest has no separate "requires" key and the schema rejects one. Which floor
823
+ # depends on what the registers are, not merely that there are some.
824
+ dependencies = dict(
825
+ spec.get("dependencies", {}),
826
+ **({"metadata": ">=" + metadata_floor(spec["registerDefs"])}
827
+ if spec.get("registerDefs") else {}))
828
+
829
+ write_json(os.path.join(base, "manifest.json"), {
830
+ "packageFormat": 1,
831
+ "moduleId": spec["moduleId"],
832
+ "code": spec["code"],
833
+ # A module whose schema has already changed once carries the version that says
834
+ # so. Hard-coding 0.1.0 here quietly reverted a bump made in the package, and a
835
+ # reverted version is how an upgrade stops being applied.
836
+ "version": spec.get("version", "0.1.0"),
837
+ "dbSchemaVersion": spec.get("version", "0.1.0"),
838
+ "displayName": spec["displayName"],
839
+ "description": spec["description"],
840
+ "auditHistory": spec.get("auditHistory", "basic"),
841
+ "author": {"name": "SweetSoft"},
842
+ "license": "proprietary",
843
+ "category": "operations",
844
+ "tags": spec["tags"],
845
+ "icon": spec["icon"],
846
+ "dependencies": dependencies,
847
+ # An extension is listed under its dotted target code, so the installer knows it
848
+ # adds to another module's entity rather than declaring one of its own.
849
+ "entities": dict(
850
+ [(code, "./entities/%s.json" % code) for code in entities]
851
+ + [(target, "./entities/%s.json" % target)
852
+ for target in spec.get("extends", {})]),
853
+ })
854
+
855
+ # Written before the entities read as a matter of form only \u2014 the installer resolves
856
+ # the declaration itself \u2014 but a register names the entity holding its state, so the
857
+ # file that owns the invariants belongs beside the module root, not under ui/.
858
+ if spec.get("registerDefs"):
859
+ write_json(os.path.join(base, "registers.json"), spec["registerDefs"])
860
+
861
+ built = collections.OrderedDict(
862
+ (code, build_entity(code, entity_spec)) for code, entity_spec in entities.items())
863
+ for code, entity in built.items():
864
+ write_json(os.path.join(base, "entities", code + ".json"), entity)
865
+
866
+ for target, fields in spec.get("extends", {}).items():
867
+ built[target] = build_extension(spec["code"], target, fields,
868
+ (spec.get("extendRegistry") or {}).get(target),
869
+ (spec.get("extendDescription") or {}).get(target))
870
+ write_json(os.path.join(base, "entities", target + ".json"), built[target])
871
+
872
+ # `hideInFolder` keeps an entity off the folder's own list, but a menu item opens its
873
+ # view INSIDE the folder, and the platform refuses an entity the folder does not grant
874
+ # ("Entity 'X' is not accessible in folder 'Y'") - only when someone clicks it. So
875
+ # whatever the menu reaches is granted, hidden or not.
876
+ in_menu = set(menu_view_codes(spec["menu"]))
877
+ write_json(os.path.join(base, "ui", "folders.json"), {
878
+ "label": spec["label"],
879
+ "description": spec["description"],
880
+ "color": spec["color"],
881
+ "entities": {code: ({"quickAdd": True} if entities[code].get("quickAdd") else {})
882
+ for code in entities
883
+ if not entities[code].get("hideInFolder") or plural(code) in in_menu},
884
+ })
885
+
886
+ layouts = collections.OrderedDict()
887
+ for code, entity_spec in entities.items():
888
+ found = build_card_layout(code, entity_spec)
889
+ if found:
890
+ layouts[code + "_card"] = found
891
+ if layouts:
892
+ write_json(os.path.join(base, "ui", "card_layouts.json"), layouts)
893
+
894
+ write_json(os.path.join(base, "ui", "data_views.json"), {
895
+ plural(code): {
896
+ "label": entities[code]["desc"],
897
+ "dataSources": [data_source(code, built[code])],
898
+ }
899
+ for code in entities
900
+ })
901
+
902
+ write_json(os.path.join(base, "ui", "menus.json"), {
903
+ "%s_menu" % spec["code"].replace("-", "_"): {
904
+ "label": spec["label"],
905
+ "folders": [spec["code"]],
906
+ "items": spec["menu"],
907
+ }
908
+ })
909
+
910
+ roles = readable_lookups(spec["roles"], built, spec["code"])
911
+ write_json(os.path.join(base, "security", "roles.json"), roles)
912
+
913
+ for provider, contract in dependency_contracts(dict(spec, roles=roles), built,
914
+ dependencies).items():
915
+ write_json(os.path.join(base, "deps", provider + ".json"), contract)
916
+
917
+ if spec.get("uk"):
918
+ write_json(os.path.join(base, "translations", "uk-UA.json"),
919
+ build_translations(spec))
920
+
921
+ if spec.get("settings"):
922
+ write_json(os.path.join(base, "settings.json"), spec["settings"])
923
+
924
+ copied = apply_authored(base, spec["code"])
925
+
926
+ print("%-10s %2d entities -> %s%s" % (spec["code"], len(entities), base,
927
+ " (+%d authored)" % copied if copied else ""))
928
+
929
+
930
+ def _merge(into, extra):
931
+ """`extra` laid over `into`, nested dictionaries merged and every other value replaced."""
932
+ for key, value in extra.items():
933
+ if isinstance(value, dict) and isinstance(into.get(key), dict):
934
+ _merge(into[key], value)
935
+ else:
936
+ into[key] = value
937
+ return into
938
+
939
+
940
+ def apply_authored(base, module):
941
+ """Copy the project's `authored/<module>/` files into the module just emitted.
942
+
943
+ What the generator cannot derive - an action and its DSL - is written once in the
944
+ project and laid over every regeneration, so `genmodule` never drops it. A translations
945
+ or roles file is merged into the generated one, and so is a dependency contract - the
946
+ library functions an action imports from that dependency (`"functions"`), beside the
947
+ entities the generator found it uses; any other file the generator already wrote is
948
+ refused, since replacing it would quietly discard what was derived. Returns how many
949
+ files were laid over.
950
+ """
951
+ source = binding.authored(module)
952
+ if not source:
953
+ return 0
954
+ count = 0
955
+ for directory, _, names in os.walk(source):
956
+ for name in sorted(names):
957
+ if name.endswith((".pyc", ".pyo")):
958
+ continue
959
+ path = os.path.join(directory, name)
960
+ relative = os.path.relpath(path, source)
961
+ target = os.path.join(base, relative)
962
+ # Translations, roles and contracts are merged into the generated file: an action's
963
+ # label, its Execute right and its imports live beside what was derived.
964
+ contract = os.path.dirname(relative) == "deps" and name.endswith(".json")
965
+ if contract and not os.path.isfile(target):
966
+ raise ValueError("authored/%s/%s: %s is not a dependency of %s"
967
+ % (module, relative.replace(os.sep, "/"), name[:-5], module))
968
+ # An entity file adds what the 1C metadata cannot give - formula columns - to the
969
+ # entity the generator derived; it never stands for an entity of its own.
970
+ entity = os.path.dirname(relative) == "entities" and name.endswith(".json")
971
+ if entity and not os.path.isfile(target):
972
+ raise ValueError("authored/%s/%s: the generator wrote no entity %s"
973
+ % (module, relative.replace(os.sep, "/"), name[:-5]))
974
+ if (os.path.dirname(relative) == "translations" and name.endswith(".json") or contract or entity
975
+ or relative.replace(os.sep, "/") == "security/roles.json"):
976
+ with codecs.open(path, "r", "utf-8") as f:
977
+ extra = json.load(f)
978
+ generated = {}
979
+ if os.path.isfile(target):
980
+ with codecs.open(target, "r", "utf-8") as f:
981
+ generated = json.load(f)
982
+ write_json(target, _merge(generated, extra))
983
+ else:
984
+ if os.path.exists(target):
985
+ raise ValueError("authored/%s/%s would replace a file the generator wrote"
986
+ % (module, relative.replace(os.sep, "/")))
987
+ if not os.path.isdir(os.path.dirname(target)):
988
+ os.makedirs(os.path.dirname(target))
989
+ shutil.copyfile(path, target)
990
+ count += 1
991
+ return count
992
+
993
+
994
+ # ------------------------------------------------------------------ generated modules
995
+
996
+ MODULE_UUID = "b1000000-0000-0000-0000-0000000000%02d"
997
+
998
+
999
+ def module_uuid(code, index):
1000
+ """A stable id for a generated module.
1001
+
1002
+ The counter numbers modules by their position in the contour map, which is fine while
1003
+ there is one map and wrong the moment there are two: a second project's twelfth module
1004
+ drew the first project's twelfth id, and install refused it - "moduleId is already used
1005
+ by a different installed module 'catering'" - after creating 359 tables. Two projects
1006
+ meant for the SAME tenant cannot number themselves independently.
1007
+
1008
+ The first project keeps the counter, because its modules are installed in live tenants
1009
+ and a module that changes its id is a DIFFERENT module to the platform: the old one
1010
+ would be orphaned and the new one installed beside it. Every other project derives its
1011
+ ids from the module code instead, which collides with nothing and needs no registry.
1012
+ """
1013
+ if os.path.basename(paths.PROJECT) == "project":
1014
+ return MODULE_UUID % (10 + index)
1015
+ digest = hashlib.sha1(code.encode("utf-8")).hexdigest()
1016
+ return "b2000000-0000-0000-0000-%s" % digest[:12]
1017
+
1018
+ FAMILY_GROUPS = [
1019
+ ("documents", "Documents", "file-text", 10, ("Document", "DocumentJournal")),
1020
+ ("catalogs", "Catalogs", "database", 20, ("Reference", "Chrc", "Acc")),
1021
+ ("registers", "Registers", "table", 30, ("InfoRg", "AccumRg")),
1022
+ ]
1023
+
1024
+
1025
+ def dependencies_of(entities):
1026
+ """Which other modules this one's references reach into - and whose registers its
1027
+ posting columns bind to, since a binding names `module.register` and the register has
1028
+ to be installed before the column that posts to it."""
1029
+ found = set()
1030
+ for entity in entities.values():
1031
+ for field in entity["fields"]:
1032
+ spec = field[1]
1033
+ if named(spec, "ref:") and "." in spec:
1034
+ found.add(spec[len("ref:"):].split(".")[0])
1035
+ for column in (entity.get("registry") or {}).values():
1036
+ register = (column.get("params") or {}).get("register") or ""
1037
+ if "." in register:
1038
+ found.add(register.split(".")[0])
1039
+ return found
1040
+
1041
+
1042
+ def authored_reports(module):
1043
+ """The reports a module's authored `ui/reports.json` declares, in file order: [(code, label)].
1044
+
1045
+ The generator writes the menu, and a report nobody can reach from it is a report nobody
1046
+ opens, so the reports the project authors for a generated module get a section of their own
1047
+ (`auto_menu`). Their labels are the report's own; the Ukrainian ones come with the authored
1048
+ translations, merged like any other."""
1049
+ source = binding.authored(module)
1050
+ path = source and os.path.join(source, "ui", "reports.json")
1051
+ if not path or not os.path.isfile(path):
1052
+ return []
1053
+ with codecs.open(path, "r", "utf-8") as f:
1054
+ reports = json.load(f, object_pairs_hook=collections.OrderedDict)
1055
+ return [(code, report.get("label") or code) for code, report in reports.items()]
1056
+
1057
+
1058
+ def auto_menu(entities, module=None):
1059
+ """Menu sections by 1C family, and the module's authored reports. Line entities are
1060
+ reached through their parent."""
1061
+ menu = {}
1062
+ reports = authored_reports(module) if module else []
1063
+ if reports:
1064
+ menu["reports"] = {"orderNum": 5, "label": "Reports", "icon": "file-earmark-spreadsheet",
1065
+ "children": dict((code, {"itemType": "R", "reportCode": code,
1066
+ "orderNum": 6 + n, "label": label})
1067
+ for n, (code, label) in enumerate(reports))}
1068
+ for group, label, icon, order, families in FAMILY_GROUPS:
1069
+ children = {}
1070
+ item_order = order + 1
1071
+ for code, entity in entities.items():
1072
+ if entity.get("lineOf") or entity.get("family") not in families:
1073
+ continue
1074
+ children[code] = {"itemType": "V", "dataViewCode": plural(code),
1075
+ "orderNum": item_order, "label": entity["desc"]}
1076
+ item_order += 1
1077
+ if children:
1078
+ menu[group] = {"orderNum": order, "label": label, "icon": icon,
1079
+ "children": children}
1080
+ return menu
1081
+
1082
+
1083
+ def auto_roles(module, entities):
1084
+ visible = [c for c, e in entities.items() if not e.get("lineOf")]
1085
+ lines = [c for c, e in entities.items() if e.get("lineOf")]
1086
+ everything = visible + lines
1087
+ return {
1088
+ "%s.admin" % module: {
1089
+ "description": "%s administrator" % module,
1090
+ "rights": dict((code, "SIUDC") for code in everything),
1091
+ },
1092
+ "%s.user" % module: {
1093
+ "description": "Works with %s data" % module,
1094
+ "rights": dict((code, "SIU") for code in everything),
1095
+ },
1096
+ "%s.viewer" % module: {
1097
+ "description": "Read-only access to %s data" % module,
1098
+ "rights": dict((code, "S") for code in everything),
1099
+ },
1100
+ }
1101
+
1102
+
1103
+ MENU_GROUP_UK = {"documents": "Документи", "catalogs": "Довідники",
1104
+ "registers": "Регістри", "reports": "Звіти"}
1105
+
1106
+ ROLE_SUFFIX_UK = {"admin": "Адміністратор", "user": "Ведення", "viewer": "Перегляд"}
1107
+
1108
+
1109
+ def auto_uk(module, entities, contour, built_menu):
1110
+ """The module's `uk` section, from the Ukrainian each entity carries.
1111
+
1112
+ Everything below the entity - field labels, dropdown options - was translated in
1113
+ `autospec`, from the same 1C synonym the English label came from. What is added here
1114
+ is the furniture around it: the folder, the views, the menu and the roles.
1115
+ """
1116
+ translated = collections.OrderedDict(
1117
+ (code, entity["uk"]) for code, entity in entities.items() if entity.get("uk"))
1118
+ if not translated:
1119
+ return None
1120
+
1121
+ label = contour.get("uk_label", contour["label"])
1122
+
1123
+ # Label only what the MENU actually holds, groups included. Two ways a label for a
1124
+ # non-existent item used to get written: a group `auto_menu` left out because no entity
1125
+ # of this module belongs to it - `catalogs` in five modules, `registers` in one - and,
1126
+ # since the register overlay could introduce an entity, one with no 1C FAMILY, which is
1127
+ # what `auto_menu` groups by. The installer reports each as "no code mapping found".
1128
+ menu = {"_": label}
1129
+ in_menu = set()
1130
+ for group, section in (built_menu or {}).items():
1131
+ menu[group] = MENU_GROUP_UK.get(group, group)
1132
+ in_menu |= set((section.get("children") or {}).keys())
1133
+ for code, entity in entities.items():
1134
+ if code in in_menu and entity.get("uk"):
1135
+ menu[code] = entity["uk"]["_"]
1136
+ # an authored report's menu item reads as the report does in the authored translation
1137
+ source = binding.authored(module)
1138
+ path = source and os.path.join(source, "translations", "uk-UA.json")
1139
+ if path and os.path.isfile(path):
1140
+ with codecs.open(path, "r", "utf-8") as f:
1141
+ reports = json.load(f).get("reports") or {}
1142
+ for code, report in reports.items():
1143
+ if code in in_menu and report.get("label"):
1144
+ menu[code] = report["label"]
1145
+
1146
+ return {
1147
+ "folder": label,
1148
+ "menu": menu,
1149
+ "views": dict((plural(code), entity["uk"]["_"])
1150
+ for code, entity in entities.items() if entity.get("uk")),
1151
+ "roles": dict(("%s.%s" % (module, key), "%s - %s" % (suffix, label))
1152
+ for key, suffix in ROLE_SUFFIX_UK.items()),
1153
+ "entities": translated,
1154
+ }
1155
+
1156
+
1157
+ def apply_registers(module, entities, overlay):
1158
+ """Fold a project's register overlay into a generated module's entities.
1159
+
1160
+ A generated entity is a dict the same shape an authored one has, so a register over
1161
+ one is the same three things it is anywhere: the columns the register needs on its
1162
+ own table, the declaration, and the `A` column each writing document binds from. The
1163
+ only thing this does that an authored spec does not is find the entity by code and
1164
+ fail loudly when the code is wrong - a silent miss here would install a register with
1165
+ nothing writing to it.
1166
+ """
1167
+ if not overlay:
1168
+ return None
1169
+
1170
+ # An entity the register needs and the infobase does not have. A dForge balance register
1171
+ # keeps its running total in a STATE entity, and 1C keeps no such table - it holds the
1172
+ # movements and computes the balance from them on every read. So the state has no 1C
1173
+ # original to generate from, and until this the overlay could add columns, bindings and
1174
+ # constraints to a generated entity but never an entity of its own.
1175
+ #
1176
+ # It is applied FIRST so the sections below can target what it introduced, and it refuses
1177
+ # a code that already exists: silently shadowing an entity the generator derived from the
1178
+ # infobase would replace a copied table with a hand-written one and nothing would say so.
1179
+ #
1180
+ # The spec is the same shape autospec builds, because everything downstream reads that
1181
+ # shape - `fields` as a list of tuples, `uk` with its `_`/`desc`/`f`/`o`. The surrogate
1182
+ # primary key is added for it like any other entity, which is what the platform's
1183
+ # "one column it mints" rule wants.
1184
+ for code, spec in (overlay.get("entities") or {}).items():
1185
+ if code in entities:
1186
+ raise KeyError("registers.py: %s already has an entity %r - an overlay entity "
1187
+ "may not shadow one the generator derived from the infobase"
1188
+ % (module, code))
1189
+ entities[code] = dict(spec)
1190
+
1191
+ # A column the generator typed from the infobase and the register needs typed as what
1192
+ # it MEANS. The one case so far is a date dimension the 1C author declared as a string:
1193
+ # the physical column is nvarchar, so the generator read nvarchar, and a text date
1194
+ # neither sorts nor compares nor matches what a posting would write into it.
1195
+ for code, columns in (overlay.get("retype") or {}).items():
1196
+ if code not in entities:
1197
+ raise KeyError("registers.py: %s has no entity %r to retype a column on"
1198
+ % (module, code))
1199
+ fields = list(entities[code]["fields"])
1200
+ for name, spec in sorted(columns.items()):
1201
+ for i, item in enumerate(fields):
1202
+ if item[0] == name:
1203
+ fields[i] = (item[0], spec) + tuple(item[2:])
1204
+ break
1205
+ else:
1206
+ raise KeyError("registers.py: %s.%s has no column %r to retype"
1207
+ % (module, code, name))
1208
+ entities[code]["fields"] = fields
1209
+
1210
+ for code, fields in (overlay.get("fields") or {}).items():
1211
+ if code not in entities:
1212
+ raise KeyError("registers.py: %s has no entity %r to add register columns to"
1213
+ % (module, code))
1214
+ entities[code]["fields"] = list(entities[code]["fields"]) + list(fields)
1215
+
1216
+ for code, columns in (overlay.get("bind") or {}).items():
1217
+ if code not in entities:
1218
+ raise KeyError("registers.py: %s has no entity %r to bind a register from"
1219
+ % (module, code))
1220
+ registry = dict(entities[code].get("registry") or {})
1221
+ registry.update(columns)
1222
+ entities[code]["registry"] = registry
1223
+
1224
+ # A rule the 1C handler enforced by refusing to post. The platform has no precondition
1225
+ # on an `A` column, so the invariant behind the refusal is declared instead - as a real
1226
+ # Postgres CHECK, which holds on every path into the table rather than only the one the
1227
+ # UI takes.
1228
+ for code, constraints in (overlay.get("constraints") or {}).items():
1229
+ if code not in entities:
1230
+ raise KeyError("registers.py: %s has no entity %r to constrain" % (module, code))
1231
+ entities[code]["constraints"] = dict(entities[code].get("constraints") or {},
1232
+ **constraints)
1233
+
1234
+ # An index a posting action's lookup needs on a generated table. 1C keeps its information
1235
+ # registers indexed by their dimensions; the generator copies the rows, not the index, so
1236
+ # a slice "the price in force for this product here" scans the whole table - fine once,
1237
+ # a timeout when a document asks it per line.
1238
+ for code, indexes in (overlay.get("indexes") or {}).items():
1239
+ if code not in entities:
1240
+ raise KeyError("registers.py: %s has no entity %r to index" % (module, code))
1241
+ entities[code]["indexes"] = dict(entities[code].get("indexes") or {}, **indexes)
1242
+
1243
+ # The Ukrainian for what was just added. Everything else in a generated module was
1244
+ # translated from the 1C synonym it came from; these columns have no 1C original, so
1245
+ # the only place their Ukrainian can come from is the overlay that introduced them.
1246
+ for code, labels in (overlay.get("uk") or {}).items():
1247
+ if code not in entities:
1248
+ raise KeyError("registers.py: %s has no entity %r to translate" % (module, code))
1249
+ uk = entities[code].get("uk")
1250
+ if uk is not None:
1251
+ uk["f"] = dict(uk.get("f") or {}, **labels)
1252
+
1253
+ # Another module's document this one posts: its own columns and posting columns on it, in
1254
+ # an extension (`extends`) rather than on an entity of this module.
1255
+ if overlay.get("extend"):
1256
+ OVERLAY_EXTENDS[module] = overlay["extend"]
1257
+
1258
+ # What the module consumes that its files do not show, for its dependency contracts:
1259
+ # [("provider_module.entity", "<kind>:<symbol>"), ...].
1260
+ if overlay.get("uses"):
1261
+ DECLARED_USES[module] = list(overlay["uses"])
1262
+
1263
+ # A generated module is 0.1.0 until its schema has to change under rows already
1264
+ # installed. The platform's upgrade adds columns and never removes a constraint, so a
1265
+ # rule taken off a column needs an upgrade migration (`authored/<module>/migrations/
1266
+ # <version>.sql`), which runs only on a version above the installed one - and only on
1267
+ # a platform that runs migrations at all, which is what `requires` gates on.
1268
+ declared = dict((key, overlay[key]) for key in ("version", "requires") if overlay.get(key))
1269
+ if declared:
1270
+ DECLARED_MODULE[module] = declared
1271
+
1272
+ return overlay.get("defs") or None
1273
+
1274
+
1275
+ # module -> [(qualified provider entity, provenance token)], from the register overlay.
1276
+ DECLARED_USES = {}
1277
+ # module -> {"version": "x.y.z", "requires": {system module: range}}, from the overlay.
1278
+ DECLARED_MODULE = {}
1279
+ # What a module's register overlay adds to another module's entity: {module: {target: {...}}}.
1280
+ OVERLAY_EXTENDS = {}
1281
+
1282
+
1283
+ def auto_spec(module, entities, contour, index, registerDefs=None):
1284
+ """A generated module's SPEC, around the entities autospec built."""
1285
+ extends = collections.OrderedDict(autospec.EXTENSIONS.get(module, {}))
1286
+ overlaid = OVERLAY_EXTENDS.get(module, {})
1287
+ for target, ext in overlaid.items():
1288
+ extends[target] = list(extends.get(target, [])) + list(ext.get("fields", []))
1289
+ deps = dependencies_of(entities) - {module}
1290
+ deps |= set(target.split(".")[0] for target in extends) - {module}
1291
+ deps |= dependencies_of(dict((target, {"fields": ext.get("fields", []),
1292
+ "registry": ext.get("registry")})
1293
+ for target, ext in overlaid.items())) - {module}
1294
+ return {
1295
+ "extendRegistry": dict((t, e.get("registry")) for t, e in overlaid.items() if e.get("registry")),
1296
+ "extendUk": dict((t, e.get("uk")) for t, e in overlaid.items() if e.get("uk")),
1297
+ "extendDescription": dict((t, e.get("desc")) for t, e in overlaid.items() if e.get("desc")),
1298
+ **({"registerDefs": registerDefs} if registerDefs else {}),
1299
+ "extends": extends,
1300
+ "uk": auto_uk(module, entities, contour, auto_menu(entities, module)),
1301
+ "code": module,
1302
+ "moduleId": module_uuid(module, index),
1303
+ "displayName": contour["label"],
1304
+ "description": contour.get("description", contour["label"]),
1305
+ "label": contour["label"],
1306
+ "color": contour.get("color", "#78909C"),
1307
+ "icon": contour.get("icon", "box"),
1308
+ "tags": contour.get("tags", [module]),
1309
+ "auditHistory": "basic",
1310
+ "dependencies": dict(
1311
+ [(name, ">=0.1.0") for name in sorted(deps)]
1312
+ + sorted(DECLARED_MODULE.get(module, {}).get("requires", {}).items())),
1313
+ **({"version": DECLARED_MODULE[module]["version"]}
1314
+ if DECLARED_MODULE.get(module, {}).get("version") else {}),
1315
+ "entities": entities,
1316
+ "menu": auto_menu(entities, module),
1317
+ "roles": auto_roles(module, entities),
1318
+ }
1319
+
1320
+
1321
+ # --------------------------------------------------------------------------- specs
1322
+
1323
+ import autospec
1324
+ import binding
1325
+
1326
+
1327
+ def resolve_enums(spec, model):
1328
+ """Turn every `drop1c:<enum>` in an authored spec into the enum's real options."""
1329
+ uk_entities = spec.get("uk", {}).get("entities", {})
1330
+ for code, entity in spec["entities"].items():
1331
+ fields = []
1332
+ for item in entity.get("fields", []):
1333
+ if not named(item[1], "drop1c:"):
1334
+ fields.append(item)
1335
+ continue
1336
+ found = autospec.enum_spec(model, item[1][len("drop1c:"):])
1337
+ if not found:
1338
+ raise ValueError("no such enum: %s" % item[1])
1339
+ fields.append((item[0], found["spec"]) + tuple(item[2:]))
1340
+ uk = uk_entities.get(code)
1341
+ if uk is not None:
1342
+ uk.setdefault("o", {})[item[0]] = found["uk"]
1343
+ entity["fields"] = fields
1344
+ return spec
1345
+
1346
+
1347
+ def with_extensions(spec, module):
1348
+ """The generated extensions of an authored module, beside the ones its spec declares - a
1349
+ bridge the spec posts on another module's document. Assigning the generated ones dropped
1350
+ the spec's own without a word."""
1351
+ extends = collections.OrderedDict(autospec.EXTENSIONS[module])
1352
+ for target, fields in (spec.get("extends") or {}).items():
1353
+ extends[target] = list(extends.get(target, [])) + list(fields)
1354
+ return extends
1355
+
1356
+
1357
+ def main():
1358
+ generated, model = autospec.build()
1359
+
1360
+ hand = dict((code, dict(spec)) for code, spec in binding.specs().items())
1361
+ contours = binding.contours()
1362
+ overlays = binding.registers()
1363
+
1364
+ unknown = set(overlays) - set(generated)
1365
+ if unknown:
1366
+ raise KeyError("registers.py declares registers over %s, which %s"
1367
+ % (", ".join(sorted(unknown)),
1368
+ "is not a generated module - author it in a spec instead"))
1369
+ defs = dict((module, apply_registers(module, entities, overlays.get(module)))
1370
+ for module, entities in generated.items())
1371
+
1372
+ # One authored spec owns the base catalogs, and the generated entities for that
1373
+ # module - the integrator's classifiers and the stubs for catalogs the port only
1374
+ # points at - are added to it rather than living in a module of their own. Which
1375
+ # spec that is belongs to the project, so the project says so.
1376
+ absorbing = next((code for code, spec in sorted(hand.items())
1377
+ if spec.get("absorbsGenerated")), None)
1378
+ if absorbing is None:
1379
+ for module, spec in sorted(hand.items()):
1380
+ if module in autospec.EXTENSIONS:
1381
+ spec["extends"] = with_extensions(spec, module)
1382
+ emit_module(resolve_enums(spec, model))
1383
+ for index, (module, entities) in enumerate(generated.items()):
1384
+ emit_module(auto_spec(module, entities, contours.CONTOURS[module], index,
1385
+ defs.get(module)))
1386
+ return
1387
+
1388
+ masterdata = hand[absorbing]
1389
+ added = generated.pop(absorbing, {})
1390
+
1391
+ # A register declared over the absorbing module's GENERATED entities. Its overlay was
1392
+ # applied above like every other module's, but the absorbing module is emitted from its
1393
+ # authored spec rather than from `auto_spec`, which is where `registerDefs` is otherwise
1394
+ # threaded through - so without this the declaration is computed and silently dropped,
1395
+ # constraints and Ukrainian landing while the register itself never appears. The guard
1396
+ # at the top of this function cannot catch it either: the absorbing module IS generated
1397
+ # when that runs, and stops being so one line above.
1398
+ if defs.get(absorbing):
1399
+ masterdata["registerDefs"] = dict(masterdata.get("registerDefs") or {},
1400
+ **defs[absorbing])
1401
+ merged = collections.OrderedDict(masterdata["entities"])
1402
+ merged.update(added)
1403
+ masterdata["entities"] = merged
1404
+
1405
+ # The authored Ukrainian wins over anything derived from the dictionary; the derived
1406
+ # entries fill in the entities the hand-written spec never covered.
1407
+ uk = dict(masterdata["uk"])
1408
+ uk_entities = collections.OrderedDict(
1409
+ (code, entity["uk"]) for code, entity in added.items() if entity.get("uk"))
1410
+ uk_entities.update(uk.get("entities", {}))
1411
+ uk["entities"] = uk_entities
1412
+ uk["views"] = dict(uk.get("views", {}))
1413
+ for code, entity in added.items():
1414
+ if entity.get("uk"):
1415
+ uk["views"].setdefault(plural(code), entity["uk"]["_"])
1416
+ masterdata["uk"] = uk
1417
+
1418
+ hand[absorbing] = masterdata
1419
+ for module, spec in hand.items():
1420
+ if module in autospec.EXTENSIONS:
1421
+ spec["extends"] = with_extensions(spec, module)
1422
+
1423
+ for module in sorted(hand):
1424
+ emit_module(resolve_enums(hand[module], model))
1425
+
1426
+ for index, (module, entities) in enumerate(generated.items()):
1427
+ emit_module(auto_spec(module, entities, contours.CONTOURS[module], index,
1428
+ defs.get(module)))
1429
+
1430
+
1431
+ if __name__ == "__main__":
1432
+ main()