@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,964 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Extracted 1C model -> genmodule specs, for the whole infobase.
5
+
6
+ `spec_fleet.py` and `spec_shipping.py` were written by hand, which was the right way to
7
+ learn what a good port looks like and the wrong way to do 124 more objects. This builds
8
+ the same spec structure from the extracted model instead: names through `naming.py`,
9
+ field types from the type patterns, references resolved against a registry of everything
10
+ being ported, tabular sections as their own line entities, and the standard 1C columns
11
+ (`_Code`, `_Description`, `_Number`, `_Posted`, `_Period`, `_Recorder`) taken from the
12
+ physical table rather than assumed.
13
+
14
+ What it deliberately does not do:
15
+
16
+ * it does not wire accumulation registers as `columnType: "A"` columns. A register
17
+ column needs the posting logic that fills it, which is ported per contour; until then
18
+ the register is an ordinary table, which is also exactly what a direct data copy
19
+ needs. See docs/design-migration.md.
20
+ * it does not invent Ukrainian. Base labels are English, derived from the 1C synonym
21
+ through the same dictionary; `translations/uk-UA.json` is authored per contour as it
22
+ is reviewed, as it already is for fleet, shipping and masterdata.
23
+
24
+ python tools/autospec.py # what would be generated, per module
25
+ python tools/autospec.py <module> # one module's entities, in detail
26
+ """
27
+ from __future__ import print_function
28
+
29
+ import codecs
30
+ import collections
31
+ import json
32
+ import os
33
+ import re
34
+ import sys
35
+
36
+ import attrs
37
+ import binding
38
+
39
+ contours = binding.contours()
40
+ import meta
41
+ import naming
42
+
43
+ # 1C objects that are already entities in a hand-written spec: the project says which
44
+ # (`contours.HAND_ENTITIES`), since which objects were authored is the project's choice.
45
+ HAND_ENTITIES = binding.hand_entities()
46
+
47
+ # 1C catalogs the port does not own but fields point at. Each gets a minimal entity in
48
+ # masterdata - key, code, name, deletion mark - so the reference survives as a reference
49
+ # instead of decaying into an opaque GUID. Filled by resolve(), emitted by stubs().
50
+ STUBS = collections.Counter()
51
+
52
+ # Reference targets that are not catalogs (documents, business processes, the vendor's
53
+ # own registers) and composite references with several targets. These become a plain
54
+ # uuid column: the value is kept for the migration, but there is nothing to point it at.
55
+ RAW = collections.Counter()
56
+ # (physical type, what the metadata claimed) -> how many fields disagreed.
57
+ MISTYPED = collections.Counter()
58
+
59
+ TYPE_RE = re.compile(r"^(string|number|bool|date)")
60
+
61
+ # Physical columns every family carries, and what the port calls them.
62
+ RESERVED = {"code", "name", "deletion_mark", "is_folder", "parent", "owner",
63
+ "doc_no", "doc_date", "posted", "period", "recorder", "line_no",
64
+ "is_active", "record_kind"}
65
+
66
+ # Those same columns in Ukrainian. They come from 1C's own vocabulary rather than from a
67
+ # synonym, so they are named here instead of going through the dictionary.
68
+ STANDARD_UK = {
69
+ "code": u"Код",
70
+ "name": u"Найменування",
71
+ "deletion_mark": u"Позначка видалення",
72
+ "is_folder": u"Група",
73
+ "parent": u"Батьківська група",
74
+ "owner": u"Власник",
75
+ "doc_no": u"Номер",
76
+ "doc_date": u"Дата",
77
+ "posted": u"Проведено",
78
+ "deletion_mark_doc": u"Позначка видалення",
79
+ "period": u"Період",
80
+ "recorder": u"Документ-реєстратор",
81
+ "line_no": u"№ рядка",
82
+ "record_kind": u"Вид руху",
83
+ "is_active": u"Активність",
84
+ "account_kind": u"Вид рахунку",
85
+ "off_balance": u"Позабалансовий",
86
+ "totals_by_analytics": u"Підсумки за аналітикою",
87
+ }
88
+
89
+ RECORD_KIND_UK = {"receipt": u"Прихід", "expense": u"Витрата"}
90
+
91
+ # The three kinds of account, in the words a Ukrainian accountant uses for them.
92
+ ACCOUNT_KIND_UK = {"active": u"Активний", "passive": u"Пасивний",
93
+ "active_passive": u"Активно-пасивний"}
94
+
95
+ # The standard columns, and the physical column each one is copied from. `_Recorder` is
96
+ # a composite reference: `_RecorderRRef` alone on a register that only one document kind
97
+ # writes, `_RecorderTRef` + `_RecorderRRef` where several do.
98
+ STANDARD_PHYSICAL = {
99
+ "code": ["_Code"],
100
+ "name": ["_Description"],
101
+ "deletion_mark": ["_Marked"],
102
+ "is_folder": ["_Folder"],
103
+ "parent": ["_ParentIDRRef"],
104
+ "owner": ["_OwnerIDRRef", "_OwnerID_RRRef"],
105
+ "doc_no": ["_Number"],
106
+ "doc_date": ["_Date_Time"],
107
+ "posted": ["_Posted"],
108
+ "period": ["_Period"],
109
+ "recorder": ["_RecorderRRef"],
110
+ "record_kind": ["_RecordKind"],
111
+ "is_active": ["_Active"],
112
+ "account_kind": ["_Kind"],
113
+ "off_balance": ["_OffBalance"],
114
+ }
115
+
116
+ # (module, entity) -> {dForge column: physical column}, filled while generating. The
117
+ # migration reads this rather than re-deriving it, so the copy cannot drift from the
118
+ # schema it copies into.
119
+ COLUMNS = collections.OrderedDict()
120
+
121
+ # owning module -> {"<module>.<entity>": [field spec, ...]}, for the columns diverted by
122
+ # `contours.EXTENSIONS` because they would otherwise invert a dependency.
123
+ EXTENSIONS = collections.OrderedDict()
124
+
125
+
126
+ def _pinned():
127
+ """(1C table, physical column) -> the dForge column name already given to it.
128
+
129
+ A name the generator emitted and the loader filled is a column in a tenant, holding rows.
130
+ Renaming it is a migration, and the generator does not know it is doing one: the
131
+ transliteration dictionary improved after the standard configuration's modules were
132
+ loaded (`налоговым` became `tax`, `валютный` `currency`), and a regeneration renamed
133
+ installed columns. So a column the project's column map already names keeps its name; a
134
+ better dictionary applies to columns that are new. Read once, from the map as committed,
135
+ before this run rewrites it.
136
+ """
137
+ import paths
138
+ path = paths.derived("columnmap.json")
139
+ if not os.path.isfile(path):
140
+ return {}
141
+ with codecs.open(path, "r", "utf-8") as f:
142
+ mapping = json.load(f)
143
+ names = collections.defaultdict(set)
144
+ for entities in mapping.values():
145
+ for spec in entities.values():
146
+ table = spec.get("table")
147
+ for name, source in (spec.get("columns") or {}).items():
148
+ if table and isinstance(source, type(u"")) and source.startswith("_Fld"):
149
+ names[(table, source)].add(name)
150
+ # One 1C column read into two of ours (a magnitude and its direction) pins neither.
151
+ return dict((key, found.pop()) for key, found in names.items() if len(found) == 1)
152
+
153
+
154
+ def _pinned_enums():
155
+ """1C enum table -> the option codes the project's column map loaded it under, in order."""
156
+ import paths
157
+ path = paths.derived("columnmap.json")
158
+ if not os.path.isfile(path):
159
+ return {}
160
+ with codecs.open(path, "r", "utf-8") as f:
161
+ mapping = json.load(f)
162
+ found = {}
163
+ for entities in mapping.values():
164
+ for spec in entities.values():
165
+ for enum in (spec.get("enums") or {}).values():
166
+ if isinstance(enum, dict) and enum.get("table") and enum.get("options"):
167
+ found.setdefault(enum["table"], list(enum["options"]))
168
+ return found
169
+
170
+
171
+ PINNED = _pinned()
172
+ PINNED_ENUMS = _pinned_enums()
173
+
174
+
175
+ def column_name(field, used, table, source, spec, max_len=48):
176
+ """The column's name: the one the project already gave this 1C column, else a new one.
177
+
178
+ The map names a reference by its key column (`<name>_id`); the field is the name
179
+ without it. Any other column is named as it stands, `_id` or not."""
180
+ pinned = PINNED.get((table, source)) if source else None
181
+ if pinned and spec.startswith("ref:"):
182
+ pinned = pinned[:-3] if pinned.endswith("_id") else None
183
+ if pinned and pinned not in used:
184
+ used.add(pinned)
185
+ return pinned
186
+ return naming.unique(field["name"], used, max_len)
187
+
188
+
189
+ # --------------------------------------------------------------------------- loading
190
+
191
+ class Model(object):
192
+ """Everything the generator reads, loaded once."""
193
+
194
+ def __init__(self):
195
+ self.objects = attrs.load_json("objects.json")
196
+ self.types = attrs.load_json("typemap.json")
197
+ self.report = {}
198
+ for row in attrs.load_json("report.json"):
199
+ self.report[(row["family"], row["name"])] = row
200
+ self.report.setdefault(row["name"], row)
201
+ self.by_name = {}
202
+ for obj in self.objects:
203
+ if obj.get("name"):
204
+ self.by_name[(obj["family"], obj["name"])] = obj
205
+ self.by_name.setdefault(obj["name"], obj)
206
+ with codecs.open(os.path.join(attrs.OUT, "schema.json"), "r", "utf-8") as f:
207
+ schema = json.load(f)
208
+ tables = schema if isinstance(schema, list) else schema["tables"]
209
+ self.tables = {t["name"]: t for t in tables}
210
+ self.registry = {} # (family, 1C name) -> "module.entity"
211
+ self._enums = {}
212
+ # DBNames keys a Fld entry by the attribute's GUID, which is how a ported column
213
+ # finds the `_FldNNN` holding its data.
214
+ with codecs.open(os.path.join(attrs.OUT, "dbnames.json"), "r", "utf-8") as f:
215
+ names = json.load(f)
216
+ self.fld = dict((e["guid"], e["physical"]) for e in names["entries"]
217
+ if e["type"] == "Fld")
218
+ # A tabular section's own table and line-number column, keyed the same way.
219
+ self.vt = dict((e["guid"], e["physical"]) for e in names["entries"]
220
+ if e["type"] == "VT")
221
+ self.line_no = dict((e["guid"], e["physical"]) for e in names["entries"]
222
+ if e["type"] == "LineNo")
223
+
224
+ def table_columns(self, table):
225
+ found = self.tables.get(table or "")
226
+ return set(c["column"] for c in found["columns"]) if found else set()
227
+
228
+ def row(self, name, family=None):
229
+ """One object's report entry. A name can belong to two families - the infobase
230
+ holds a certificates catalog and a certificates register under the very same
231
+ name - so the family is part of the address wherever it is known."""
232
+ return self.report.get((family, name)) or self.report.get(name) or {}
233
+
234
+ def fields(self, name, family=None):
235
+ """The object's attributes, resolved. The family-aware `attrs.fields_of`."""
236
+ obj = self.by_name.get((family, name)) or self.by_name.get(name)
237
+ if not obj:
238
+ return []
239
+ text = meta.load(obj["guid"])
240
+ if text is None:
241
+ return []
242
+ found = []
243
+ attrs.collect(meta.parse(text), None, None, self.types, found)
244
+ return found
245
+
246
+ def physical(self, name, family=None):
247
+ """The object's own table, and its standard column names."""
248
+ row = self.row(name, family)
249
+ table = self.tables.get(row.get("physical") or "")
250
+ columns = {c["column"] for c in table["columns"]} if table else set()
251
+ return row.get("physical"), columns
252
+
253
+ def sections(self, name, family=None):
254
+ """Tabular-section name -> its physical table, for the sections of one object."""
255
+ physical, _ = self.physical(name, family)
256
+ if not physical:
257
+ return {}
258
+ prefix = physical + "_VT"
259
+ return {n: self.tables[n] for n in self.tables if n.startswith(prefix)}
260
+
261
+ def enum_options(self, name):
262
+ """Enum object -> [(code, label, label_uk)], its values in declaration order."""
263
+ if name in self._enums:
264
+ return self._enums[name]
265
+ obj = self.by_name.get(name)
266
+ options = []
267
+ if obj:
268
+ text = meta.load(obj["guid"])
269
+ if text is not None:
270
+ taken = set()
271
+ for value in attrs.enum_values(meta.parse(text), name):
272
+ options.append((naming.unique(value, taken, 28,
273
+ avoid_reserved=False),
274
+ naming.label(value),
275
+ naming.label_uk(value) or naming.label(value)))
276
+ # The option codes the project already loaded rows under keep their spelling, by the
277
+ # same rule as column names (see `_pinned`): a stored value that changes spelling is
278
+ # a row that no longer matches its option.
279
+ table, _ = self.physical(name, "Enum")
280
+ kept = PINNED_ENUMS.get(table)
281
+ if kept and len(kept) == len(options):
282
+ options = [(code, label if code == fresh else
283
+ re.sub(r"_\d+$", "", code).replace("_", " ").capitalize(), uk)
284
+ for code, (fresh, label, uk) in zip(kept, options)]
285
+ self._enums[name] = options
286
+ return options
287
+
288
+
289
+ # --------------------------------------------------------------------------- types
290
+
291
+ def parse_target(part):
292
+ """'-> Reference.Номенклатура' -> ('Reference', 'Номенклатура')"""
293
+ body = part.strip()[3:]
294
+ family, _, name = body.partition(".")
295
+ return family, name
296
+
297
+
298
+ # 1C's own type for a serialized value. The attribute reads as a reference and is
299
+ # nothing of the kind: the column is `image`, and what sits in it is 1C's
300
+ # serialization - a length, a BOM and a JSON-ish body naming a type GUID.
301
+ VALUE_STORAGE = "e199ca70"
302
+
303
+
304
+ def resolve_reference(parts, model):
305
+ """The reference targets of a field -> a genmodule type, or None for a raw uuid."""
306
+ if len(parts) == 1 and VALUE_STORAGE in parts[0]:
307
+ return "blob"
308
+ targets = [parse_target(p) for p in parts]
309
+ if len(targets) != 1:
310
+ RAW[" + ".join("%s.%s" % t for t in targets)] += 1
311
+ return None
312
+ family, name = targets[0]
313
+
314
+ if family == "Enum":
315
+ options = model.enum_options(name)
316
+ if not options:
317
+ RAW["Enum.%s (no values)" % name] += 1
318
+ return None
319
+ return "drop:" + "|".join("%s=%s" % (o[0], o[1]) for o in options)
320
+
321
+ if name in HAND_ENTITIES:
322
+ return "ref:" + HAND_ENTITIES[name]
323
+ if (family, name) in model.registry:
324
+ return "ref:" + model.registry[(family, name)]
325
+ if family in ("Reference", "Chrc", "Acc"):
326
+ # A catalog, an analytics kind or a chart-of-accounts entry the port does not own.
327
+ # A stub entity keeps it a reference rather than an opaque GUID.
328
+ STUBS[(family, name)] += 1
329
+ return "ref:%s.%s" % (stub_module(), stub_code(name))
330
+
331
+ RAW["%s.%s" % (family, name)] += 1
332
+ return None
333
+
334
+
335
+ def stub_module():
336
+ """The module a stub entity belongs to, which the project names.
337
+
338
+ Moving the stubs to a project-chosen module was half the change: they were still
339
+ REFERENCED as `masterdata.<code>` and recorded under that key in the column map, so a
340
+ second project emitted its stubs into `common` and every reference into them pointed at
341
+ the first project's `masterdata`, which has no such entity. Install said so, once per
342
+ stub. The name and the placement have to come from the same answer.
343
+ """
344
+ return getattr(contours, "STUB_MODULE", None) or "masterdata"
345
+
346
+
347
+ def stub_code(name):
348
+ return contours.CODES.get(name) or naming.code(name, 40)
349
+
350
+
351
+ def physical_of(field, model, columns):
352
+ """Where this attribute's data actually sits.
353
+
354
+ DBNames gives the base name; which column carries the value depends on the type. A
355
+ reference is `_FldNNNRRef`, a composite one splits into `_FldNNN_TYPE` / `_RTRef` /
356
+ `_RRRef` and the reference half is what a copy wants, and a plain value is the base
357
+ name itself.
358
+ """
359
+ base = model.fld.get(field.get("guid"))
360
+ if not base:
361
+ return None
362
+ for candidate in (base, base + "RRef", base + "_RRRef"):
363
+ if candidate in columns:
364
+ return candidate
365
+ return None
366
+
367
+
368
+ # A reference lives in its own column: `_FldNNNRRef`, or `_FldNNN_RRRef` when the
369
+ # attribute may point at several kinds of thing. The base name holds a VALUE.
370
+ REF_STORAGE = ("RRef", "_RRRef")
371
+
372
+ # Attributes whose column belongs to another object. Reported, not silently dropped.
373
+ ORPHANED = {}
374
+
375
+ # Which side an account is settled on. Active settles on the debit side, passive on the
376
+ # credit side, and active-passive on either - which is why a settlement account's balance
377
+ # is read EXPANDED (розгорнуте сальдо) rather than netted: a receivable and a payable with
378
+ # the same counterparty belong on opposite sides of a balance sheet.
379
+ #
380
+ # 1C keeps it as 0/1/2 in a numeric column, which is not an enum reference and so not
381
+ # something `enum_decoder` can read. The project turns the number into these codes in the
382
+ # query it declares for the chart, because that is machinery which already exists and is
383
+ # tested, and `WHERE account_kind = 'passive'` is worth the declaration.
384
+ ACCOUNT_KIND = "drop:active=Active|passive=Passive|active_passive=Active-passive"
385
+
386
+
387
+ def held_column(table, column):
388
+ """The physical column's own record, from the schema dump."""
389
+ for held in (table or {}).get("columns", []):
390
+ if held["column"] == column:
391
+ return held
392
+ return None
393
+
394
+
395
+ def physical_spec(held):
396
+ """A genmodule type for what the column physically is.
397
+
398
+ `len` on an `n`-type is bytes, which is why a `Строка(11)` document number reads as
399
+ `nchar(22)`; on `numeric` it is the precision.
400
+ """
401
+ kind, width, scale = held.get("type"), held.get("len") or 0, held.get("scale") or 0
402
+ if kind in ("nvarchar", "nchar"):
403
+ return "text" if width < 0 else "varchar:%d" % max(width // 2, 1)
404
+ if kind in ("varchar", "char", "sysname"):
405
+ return "text" if width < 0 else "varchar:%d" % max(width, 1)
406
+ if kind in ("text", "ntext"):
407
+ return "text"
408
+ if kind == "numeric":
409
+ return ("int:%d" % (width or 15)) if not scale else ("num:%d,%d" % (width or 15, scale))
410
+ if kind in ("int", "smallint", "bigint", "tinyint"):
411
+ return "int:10"
412
+ if kind == "datetime":
413
+ return "datetime"
414
+ if kind == "bit":
415
+ return "bool"
416
+ return "blob"
417
+
418
+
419
+ def reconcile(spec, source, table):
420
+ """A reference the physical column cannot be holding.
421
+
422
+ `РегистрСведений.Штрихкоды.Штрихкод` reads out of the metadata as a reference to
423
+ `ПланВидовХарактеристик.ТипыШтрихкодов` and is a `Строка(200)`: 1C keeps it in
424
+ `_Fld15870`, nvarchar(400), and a reference would be in `_Fld15870RRef`. Both halves of
425
+ the generator then believed their own source - the spec said `ref:`, so the column was
426
+ declared `uuid` and named `shtrihkod_id`, while the copy was pointed at the string
427
+ column - and the load found out 55 minutes and 44 million rows in, when `to_uuid` was
428
+ handed a str.
429
+
430
+ Where the two disagree the COLUMN wins. Of the two it is the one that cannot be a
431
+ misreading: whatever the metadata meant, the value is where the value is. Everything
432
+ this fixes would otherwise be caught by `migrate check`, one failed load at a time.
433
+
434
+ The same misreading has a second shape. `СубконтоДт1` is typed
435
+ `Характеристика.ВидыСубконтоХозрасчетные`: its value is a counterparty, a product, a
436
+ cash-flow item - whatever the analytic KIND on that row says - and the metadata reads it
437
+ as a reference to the chart of kinds itself. 1C stores it as a composite, `_FldNNN_TYPE`
438
+ / `_RTRef` / `_RRRef`, which a value of one reference type never is. Linked to the kinds,
439
+ not one of 884,590 loaded values was a kind: every row that resolved, resolved to a
440
+ placeholder `fill` had stood in for it, and where nothing stood in the install could not
441
+ build the foreign key at all. A single target over composite storage is a raw GUID.
442
+ """
443
+ if not source or not (spec.startswith("ref:") or spec == "rawref"):
444
+ return spec
445
+ if spec.startswith("ref:") and source.endswith("_RRRef"):
446
+ MISTYPED[("composite", spec)] += 1
447
+ return "rawref"
448
+ if source.endswith(REF_STORAGE):
449
+ return spec
450
+ held = held_column(table, source)
451
+ if not held:
452
+ return spec
453
+ if held.get("type") == "binary" and (held.get("len") or 0) == 16:
454
+ return spec
455
+ MISTYPED[(held.get("type"), spec)] += 1
456
+ return physical_spec(held)
457
+
458
+
459
+ def options_uk(field, spec, model):
460
+ """The Ukrainian labels of a dropdown's options, when it came from a 1C enum."""
461
+ if not spec.startswith("drop:"):
462
+ return None
463
+ for part in (field["type"] or "").split(","):
464
+ part = part.strip()
465
+ if part.startswith("-> Enum."):
466
+ return dict((o[0], o[2]) for o in model.enum_options(part[len("-> Enum."):]))
467
+ return None
468
+
469
+
470
+ def enum_source(field, model):
471
+ """An enum attribute's own table, and its values in the order our options are in.
472
+
473
+ 1C stores an enum value as a reference into `_EnumNNN`, where `_EnumOrder` is the
474
+ value's position in the metadata - the same order `enum_options` reads them in. That
475
+ pairing is what lets the migration turn a GUID back into an option code.
476
+ """
477
+ for part in (field.get("type") or "").split(","):
478
+ part = part.strip()
479
+ if not part.startswith("-> Enum."):
480
+ continue
481
+ name = part[len("-> Enum."):]
482
+ table, _ = model.physical(name, "Enum")
483
+ options = [o[0] for o in model.enum_options(name)]
484
+ if table and options:
485
+ return {"table": table, "options": options}
486
+ return None
487
+
488
+
489
+ def enum_spec(model, name):
490
+ """`drop1c:<enum>` -> its dropdown spec, its Ukrainian labels and its source table.
491
+
492
+ An authored spec names the 1C enum rather than restating its values, so the options,
493
+ their order and the table the migration decodes them from all come from the same
494
+ place. Restating them by hand is how a code ends up with no value behind it.
495
+ """
496
+ options = model.enum_options(name)
497
+ if not options:
498
+ return None
499
+ table, _ = model.physical(name, "Enum")
500
+ return {
501
+ "spec": "drop:" + "|".join("%s=%s" % (o[0], o[1]) for o in options),
502
+ "uk": dict((o[0], o[2]) for o in options),
503
+ "source": {"table": table, "options": [o[0] for o in options]} if table else None,
504
+ }
505
+
506
+
507
+ def field_type(raw, model):
508
+ """The extracted type string -> a genmodule type spec."""
509
+ parts = [p.strip() for p in raw.split(",")] if raw else []
510
+ # `number(15,2)` splits on its own comma; rejoin those
511
+ merged, buffer = [], ""
512
+ for part in parts:
513
+ buffer = (buffer + "," + part) if buffer else part
514
+ if buffer.count("(") == buffer.count(")"):
515
+ merged.append(buffer)
516
+ buffer = ""
517
+ if buffer:
518
+ merged.append(buffer)
519
+
520
+ refs = [p for p in merged if p.startswith("->")]
521
+ scalars = [p for p in merged if TYPE_RE.match(p)]
522
+
523
+ if refs and not scalars:
524
+ return resolve_reference(refs, model) or "rawref"
525
+ if refs and scalars:
526
+ # 1C's "any of these" - a composite value. Nothing to point at.
527
+ RAW["composite with scalar"] += 1
528
+ return "rawref"
529
+
530
+ for part in scalars:
531
+ match = re.match(r"^string\((\d+|\?)\)$", part)
532
+ if match:
533
+ return "text" if match.group(1) == "?" else "varchar:" + match.group(1)
534
+ match = re.match(r"^number\((\d+|\?),(\d+|\?)\)$", part)
535
+ if match:
536
+ precision = match.group(1) if match.group(1) != "?" else "15"
537
+ scale = match.group(2) if match.group(2) != "?" else "0"
538
+ return ("int:" + precision) if scale == "0" else ("num:%s,%s" % (precision, scale))
539
+ if part == "bool":
540
+ return "bool"
541
+ if part.startswith("date("):
542
+ return "date" if part == "date(D)" else "datetime"
543
+ return "text"
544
+
545
+
546
+ # --------------------------------------------------------------------------- entities
547
+
548
+ def standard_fields(family, columns, table, model):
549
+ """The columns 1C gives every object of this family, as spec entries."""
550
+ out = []
551
+ width = {c["column"]: c.get("len") for c in (table or {}).get("columns", [])}
552
+
553
+ # A chart of accounts is a catalog to 1C as well - code, description, hierarchy - and
554
+ # without these an account reads as its GUID in every posting that names it.
555
+ if family in ("Reference", "Chrc", "Acc"):
556
+ if "_Code" in columns:
557
+ out.append(("code", "varchar:%d" % (width.get("_Code") or 25), "Code"))
558
+ if "_Description" in columns:
559
+ out.append(("name", "varchar:%d" % (width.get("_Description") or 150), "Name"))
560
+ if "_Marked" in columns:
561
+ out.append(("deletion_mark", "bool", "Marked for deletion"))
562
+ if "_Folder" in columns:
563
+ out.append(("is_folder", "bool", "Group"))
564
+ if "_ParentIDRRef" in columns:
565
+ out.append(("parent", "rawref", "Parent group"))
566
+ if "_OwnerIDRRef" in columns or "_OwnerID_RRRef" in columns:
567
+ out.append(("owner", "rawref", "Owner"))
568
+ # A chart of accounts carries two facts no catalog does, and both are
569
+ # structural rather than attributes: which side an account is settled on,
570
+ # and whether it is off-balance. Without them the chart is one nobody can
571
+ # compute a balance from - a trial balance has to know whether a debit
572
+ # total is a balance or a contra.
573
+ if family == "Acc":
574
+ if "_Kind" in columns:
575
+ out.append(("account_kind", ACCOUNT_KIND, "Kind"))
576
+ if "_OffBalance" in columns:
577
+ out.append(("off_balance", "bool", "Off balance"))
578
+ # Not 1C's: the platform's switch for caching an account's totals by its
579
+ # analytics (registers.md 8.5 `analyticTotals.accountFlag`), the job 1C's own
580
+ # subconto totals table does. Copied from nothing, so a reload leaves it at its
581
+ # default; the accounting module sets it on install.
582
+ out.append(("totals_by_analytics", "bool", "Totals by analytics"))
583
+
584
+ elif family in ("Document", "DocumentJournal"):
585
+ if "_Number" in columns:
586
+ out.append(("doc_no", "varchar:%d" % (width.get("_Number") or 25), "Number"))
587
+ if "_Date_Time" in columns:
588
+ out.append(("doc_date", "datetime", "Date", "req"))
589
+ if "_Posted" in columns:
590
+ out.append(("posted", "bool", "Posted", "hidden"))
591
+ if "_Marked" in columns:
592
+ out.append(("deletion_mark", "bool", "Marked for deletion"))
593
+
594
+ elif family in ("InfoRg", "AccumRg"):
595
+ if "_Period" in columns:
596
+ out.append(("period", "datetime", "Period"))
597
+ if "_RecorderRRef" in columns:
598
+ out.append(("recorder", "rawref", "Recorder document"))
599
+ out.append(("line_no", "int:9", "Line no"))
600
+ if "_RecordKind" in columns:
601
+ out.append(("record_kind", "drop:receipt=Receipt|expense=Expense",
602
+ "Movement kind"))
603
+ if "_Active" in columns:
604
+ # 1C clears _Active only on a movement whose document is unposted, and none of
605
+ # those is loaded (0 of 13.8M in ПартииТоваровНаСкладах). A journal row the
606
+ # platform writes names no such column, so it takes the default - which has to be
607
+ # true, or every as-of read that drops inactive rows drops the engine's postings.
608
+ out.append(("is_active", {"dbDatatype": "bool", "fieldTypeCd": "checkbox",
609
+ "formula": "true"}, "Active"))
610
+
611
+ return out
612
+
613
+
614
+ def entity_of(name, family, module, model, taken_codes):
615
+ """One 1C object -> one spec entity, plus any line entities for its sections."""
616
+ row = model.row(name, family)
617
+ family = family or row.get("family") or "Reference"
618
+ physical, columns = model.physical(name, family)
619
+ table = model.tables.get(physical or "")
620
+ code = model.registry[(family, name)].split(".")[-1]
621
+
622
+ extracted = model.fields(name, family)
623
+
624
+ header = [f for f in extracted if not f.get("section")]
625
+ sections = collections.OrderedDict()
626
+ for field in extracted:
627
+ if field.get("section"):
628
+ sections.setdefault(field["section"], []).append(field)
629
+
630
+ fields = list(standard_fields(family, columns, table, model))
631
+ used = {n for n, _, _ in ((f[0], f[1], f[2]) for f in fields)}
632
+ used |= {code + "_id"}
633
+
634
+ uk_fields = dict((f[0], STANDARD_UK[f[0]]) for f in fields if f[0] in STANDARD_UK)
635
+ uk_options = {}
636
+ if "record_kind" in uk_fields:
637
+ uk_options["record_kind"] = dict(RECORD_KIND_UK)
638
+ if "account_kind" in uk_fields:
639
+ uk_options["account_kind"] = dict(ACCOUNT_KIND_UK)
640
+
641
+ physical_map = {code + "_id": "_IDRRef"} if family not in ("InfoRg", "AccumRg") else {}
642
+ for name_, candidates in STANDARD_PHYSICAL.items():
643
+ if any(f[0] == name_ for f in fields):
644
+ for candidate in candidates:
645
+ if candidate in columns:
646
+ physical_map[name_] = candidate
647
+ break
648
+ if "line_no" in (f[0] for f in fields) and "_LineNo" in columns:
649
+ physical_map["line_no"] = "_LineNo"
650
+ enum_map = collections.OrderedDict()
651
+
652
+ for field in header:
653
+ if naming.is_dead(field["name"]):
654
+ continue
655
+ source = physical_of(field, model, columns)
656
+ spec = field_type(field["type"], model)
657
+ column = column_name(field, used, physical, source, spec)
658
+ spec = reconcile(spec, source, table)
659
+ label = naming.label(field.get("synonym") or field["name"])
660
+
661
+ owner = contours.EXTENSIONS.get((module, code, column))
662
+ if owner:
663
+ EXTENSIONS.setdefault(owner, collections.OrderedDict()).setdefault(
664
+ "%s.%s" % (module, code), []).append(
665
+ (column, spec, label,
666
+ naming.label_uk(field.get("synonym") or field["name"])))
667
+ # The column moves module, not infobase: it is still a column of the host's
668
+ # 1C table, and the copy has to find it there. It is recorded against the
669
+ # owning module under the host's dotted name, which is how dForge itself
670
+ # names an extension, and carries the host's key so the 1:1 row can be
671
+ # written.
672
+ if source:
673
+ held = COLUMNS.setdefault((owner, "%s.%s" % (module, code)), {
674
+ "object": name, "family": family, "table": physical,
675
+ "columns": {code + "_id": "_IDRRef"}, "enums": {},
676
+ "extends": "%s.%s" % (module, code),
677
+ })
678
+ held["columns"][
679
+ column + "_id" if spec.startswith("ref:") else column] = source
680
+ continue
681
+
682
+ # An attribute DBNames resolves to a column this table has not got belongs to
683
+ # another object. Measured on this configuration: exactly three, `Суммовой`,
684
+ # `Валютный` and `Количественный` at `_Fld781..783`, which are columns of the
685
+ # OTHER chart of accounts (`Налоговый`) and arrive attached to this one. Emitting
686
+ # them gives three columns nothing can ever fill, which reads as data the port
687
+ # lost rather than data that was never here.
688
+ #
689
+ # The test is deliberately narrow: a name that resolved and points elsewhere. An
690
+ # attribute DBNames cannot resolve AT ALL is still emitted, because that is the
691
+ # port failing to read the metadata, and silently dropping a real column would be
692
+ # the worse of the two mistakes.
693
+ if model.fld.get(field.get("guid")) and not source:
694
+ ORPHANED[(name, field["name"])] = model.fld[field["guid"]]
695
+ continue
696
+
697
+ fields.append((column, spec, label))
698
+ translated = naming.label_uk(field.get("synonym") or field["name"])
699
+ if translated:
700
+ uk_fields[column] = translated
701
+ options = options_uk(field, spec, model)
702
+ if options:
703
+ uk_options[column] = options
704
+ if source:
705
+ physical_map[column + "_id" if spec.startswith("ref:") else column] = source
706
+ if spec.startswith("drop:"):
707
+ found = enum_source(field, model)
708
+ if found:
709
+ enum_map[column] = found
710
+
711
+ uk_desc = naming.label_uk(row.get("synonym") or name)
712
+ entity = {
713
+ "desc": naming.label(row.get("synonym") or name),
714
+ "source": u"%s (%s)" % (name, physical or "-"),
715
+ "family": family,
716
+ "fields": fields,
717
+ "uk": {"_": uk_desc, "desc": uk_desc, "f": uk_fields, "o": uk_options},
718
+ }
719
+ if any(f[0] == "name" for f in fields):
720
+ entity["toString"] = "{name}"
721
+ elif any(f[0] == "doc_no" for f in fields):
722
+ entity["toString"] = "{doc_no}"
723
+ elif any(f[0] == "code" for f in fields):
724
+ entity["toString"] = "{code}"
725
+ if family in ("InfoRg", "AccumRg"):
726
+ entity["hideInFolder"] = True
727
+
728
+ entities = collections.OrderedDict([(code, entity)])
729
+ COLUMNS[(module, code)] = {
730
+ "object": name, "family": family, "table": physical, "columns": physical_map,
731
+ "enums": enum_map,
732
+ }
733
+
734
+ # Tabular sections become line entities keyed the way 1C keys a _VT table.
735
+ for section, section_fields in sections.items():
736
+ line_code = naming.unique(u"%s %s" % (name, section), taken_codes, 48)
737
+ line_used = {"line_no", code}
738
+ line_fields = [(code, "ref:" + code, entity["desc"], "req")]
739
+ line_uk = {code: uk_desc, "line_no": STANDARD_UK["line_no"]}
740
+ line_options = {}
741
+
742
+ section_guid = section_fields[0].get("sectionGuid")
743
+ # DBNames records a section's table as the suffix alone - `_VT12640` - because
744
+ # the table is that suffix appended to the owner's own table.
745
+ suffix = model.vt.get(section_guid)
746
+ line_table = (physical + suffix) if (physical and suffix) else None
747
+ line_columns = model.table_columns(line_table)
748
+ line_physical = {}
749
+ line_enums = collections.OrderedDict()
750
+ if line_table:
751
+ owner = (physical or "") + "_IDRRef"
752
+ if owner in line_columns:
753
+ line_physical[code + "_id"] = owner
754
+ line_number = model.line_no.get(section_guid)
755
+ if line_number in line_columns:
756
+ line_physical["line_no"] = line_number
757
+
758
+ for field in section_fields:
759
+ if naming.is_dead(field["name"]):
760
+ continue
761
+ source = physical_of(field, model, line_columns)
762
+ spec = field_type(field["type"], model)
763
+ column = column_name(field, line_used, line_table, source, spec)
764
+ spec = reconcile(spec, source, model.tables.get(line_table or ""))
765
+ line_fields.append((column, spec,
766
+ naming.label(field.get("synonym") or field["name"])))
767
+ translated = naming.label_uk(field.get("synonym") or field["name"])
768
+ if translated:
769
+ line_uk[column] = translated
770
+ options = options_uk(field, spec, model)
771
+ if options:
772
+ line_options[column] = options
773
+ if source:
774
+ line_physical[column + "_id" if spec.startswith("ref:") else column] = source
775
+ if spec.startswith("drop:"):
776
+ found = enum_source(field, model)
777
+ if found:
778
+ line_enums[column] = found
779
+ section_uk = naming.label_uk(section) or naming.label(section)
780
+ line_desc_uk = u"%s - %s" % (uk_desc, section_uk)
781
+ entities[line_code] = {
782
+ "desc": "%s - %s" % (entity["desc"], naming.label(section)),
783
+ "source": u"%s / %s" % (name, section),
784
+ "family": family,
785
+ "lineOf": code,
786
+ "hideInFolder": True,
787
+ "fields": line_fields,
788
+ "uk": {"_": line_desc_uk, "desc": line_desc_uk,
789
+ "f": line_uk, "o": line_options},
790
+ }
791
+ COLUMNS[(module, line_code)] = {
792
+ "object": u"%s / %s" % (name, section), "family": family,
793
+ "table": line_table, "columns": line_physical, "enums": line_enums,
794
+ }
795
+ set_column = naming.code(section, 30) or "lines"
796
+ entity.setdefault("sets", []).append(
797
+ (set_column, line_code, naming.label(section),
798
+ code + "_id", code + "_id"))
799
+ uk_fields[set_column] = section_uk
800
+
801
+ return entities
802
+
803
+
804
+ def text_widths(model, table):
805
+ """A stub copies `_Code` and `_Description` as they are, so it must be as wide.
806
+
807
+ Guessing costs a load: `_Code` is nchar(9) in one catalog and nchar(50) in another,
808
+ and a column narrower than the one it copies from is a truncation waiting to happen.
809
+ """
810
+ found = model.tables.get(table or "")
811
+ if not found:
812
+ return {}
813
+ # `sys.columns.max_length` counts bytes, so an `nchar(50)` reports 100.
814
+ return dict((c["column"], c["len"] // 2 if c["type"].startswith("n") else c["len"])
815
+ for c in found["columns"]
816
+ if c.get("len") and c["type"] in ("char", "nchar", "varchar", "nvarchar"))
817
+
818
+
819
+ def stubs(model):
820
+ """Minimal masterdata entities for the vendor catalogs the port only points at."""
821
+ out = collections.OrderedDict()
822
+ for (family, name), hits in sorted(STUBS.items(), key=lambda kv: (-kv[1], kv[0][1])):
823
+ uk_desc = naming.label_uk(name) or naming.label(name)
824
+ code = stub_code(name)
825
+ table, columns = model.physical(name, family)
826
+ # A stub still has to be filled, or every reference into it dangles. Its three
827
+ # columns come from the same standard places any catalog's do.
828
+ COLUMNS[(stub_module(), code)] = {
829
+ "object": name, "family": family, "table": table,
830
+ "columns": dict((ours, theirs) for ours, theirs in (
831
+ (code + "_id", "_IDRRef"), ("code", "_Code"),
832
+ ("name", "_Description"), ("deletion_mark", "_Marked"))
833
+ if theirs in columns),
834
+ }
835
+ # A handful of catalogs are keyed by code alone and carry no `_Description`.
836
+ # Declaring a mandatory `name` there would leave a NOT NULL column the
837
+ # migration has nothing to put in.
838
+ named = "_Description" in columns
839
+ wide = text_widths(model, table)
840
+ fields = [("code", "varchar:%d" % wide.get("_Code", 25), "Code")]
841
+ if named:
842
+ fields.append(("name", "varchar:%d" % wide.get("_Description", 200),
843
+ "Name", "req"))
844
+ fields.append(("deletion_mark", "bool", "Marked for deletion"))
845
+ labels = {"code": STANDARD_UK["code"],
846
+ "deletion_mark": STANDARD_UK["deletion_mark"]}
847
+ if named:
848
+ labels["name"] = STANDARD_UK["name"]
849
+ # The few attributes a project reads off a catalog it does not own - a report
850
+ # printing a packaging kind's short name - as contours.STUB_FIELDS names them.
851
+ extracted = dict((f["name"], f) for f in model.fields(name, family))
852
+ for ours, attribute, kind, en, uk in getattr(contours, "STUB_FIELDS", {}).get(name, ()):
853
+ # A standard attribute the metadata does not list - the owner - is named by its
854
+ # physical column, `_OwnerIDRRef` or the reference half of a composite owner.
855
+ source = (attribute if attribute.startswith("_") and attribute in columns
856
+ else physical_of(extracted[attribute], model, columns))
857
+ COLUMNS[(stub_module(), code)]["columns"][ours] = source
858
+ fields.append((ours, kind, en))
859
+ labels[ours] = uk
860
+ out[code] = {
861
+ "desc": naming.label(name),
862
+ "source": u"%s.%s - referenced %d time(s), not ported" % (family, name, hits),
863
+ "toString": "{name}" if named else "{code}",
864
+ "hideInFolder": True,
865
+ "fields": fields,
866
+ "uk": {"_": uk_desc, "desc": uk_desc, "o": {}, "f": labels},
867
+ }
868
+ return out
869
+
870
+
871
+ # --------------------------------------------------------------------------- build
872
+
873
+ def build():
874
+ """Every contour -> {module code: {entity code: entity spec}}."""
875
+ model = Model()
876
+
877
+ # Pass one: name everything, so a reference can resolve to an entity that has not
878
+ # been generated yet.
879
+ per_module_codes = collections.defaultdict(set)
880
+ assignments = collections.OrderedDict()
881
+ for module, contour in contours.CONTOURS.items():
882
+ listed = []
883
+ for entry in contour["objects"]:
884
+ name, family = entry if isinstance(entry, tuple) else (entry, None)
885
+ family = family or model.row(name).get("family")
886
+ code = contours.CODES.get(name) or naming.unique(
887
+ name, per_module_codes[module], 48)
888
+ per_module_codes[module].add(code)
889
+ model.registry[(family, name)] = "%s.%s" % (module, code)
890
+ listed.append((name, family))
891
+ assignments[module] = listed
892
+
893
+ # Pass two: generate.
894
+ generated = collections.OrderedDict()
895
+ for module, listed in assignments.items():
896
+ entities = collections.OrderedDict()
897
+ for name, family in listed:
898
+ entities.update(
899
+ entity_of(name, family, module, model, per_module_codes[module]))
900
+ generated[module] = entities
901
+
902
+ # A stub stands in for a vendor catalog the port only POINTS at, so it has to live in
903
+ # a module everything may depend on. Which module that is belongs to the project: this
904
+ # one calls it `masterdata`, and the library knew that name, which meant a second
905
+ # project - one whose shared module is called anything else - died here on a KeyError
906
+ # with nothing to suggest what it was supposed to declare.
907
+ stubbed = stubs(model)
908
+ if stubbed:
909
+ home = stub_module()
910
+ if home not in generated:
911
+ raise SystemExit(
912
+ "autospec: %d reference(s) point at objects no contour lists, so they need "
913
+ "stub entities, and there is nowhere to put them. Name the module every "
914
+ "other one may depend on as STUB_MODULE in the project's contours.py%s"
915
+ % (len(stubbed), "" if home is None else " (%r is not a module here)" % home))
916
+ generated[home].update(stubbed)
917
+ return generated, model
918
+
919
+
920
+ def main():
921
+ generated, _ = build()
922
+ wanted = sys.argv[1] if len(sys.argv) > 1 else None
923
+
924
+ total_entities = total_fields = 0
925
+ for module, entities in generated.items():
926
+ fields = sum(len(e["fields"]) for e in entities.values())
927
+ total_entities += len(entities)
928
+ total_fields += fields
929
+ if wanted and module != wanted:
930
+ continue
931
+ print("%-12s %3d entities %5d fields" % (module, len(entities), fields))
932
+ if wanted:
933
+ for code, entity in entities.items():
934
+ print("\n %s - %s" % (code, entity["desc"]))
935
+ print(" source: %s" % entity["source"])
936
+ for field in entity["fields"]:
937
+ print(" %-42s %-28s %s"
938
+ % (field[0], field[1][:28], field[2]))
939
+
940
+ if not wanted:
941
+ print("-" * 40)
942
+ print("%-12s %3d entities %5d fields" % ("total", total_entities, total_fields))
943
+ if STUBS:
944
+ print("\n%d vendor catalogs referenced but not ported -> masterdata stubs"
945
+ % len(STUBS))
946
+ for name, hits in sorted(STUBS.items(), key=lambda kv: -kv[1])[:15]:
947
+ print(" %-44s %d" % (name, hits))
948
+ if RAW:
949
+ print("\n%d reference shapes with nothing to point at -> plain uuid"
950
+ % sum(RAW.values()))
951
+ for shape, hits in sorted(RAW.items(), key=lambda kv: -kv[1])[:15]:
952
+ print(" %-60s %d" % (shape[:60], hits))
953
+ if MISTYPED:
954
+ print("\n%d field(s) the metadata called a reference and the column is not"
955
+ % sum(MISTYPED.values()))
956
+ for (kind, claimed), hits in sorted(MISTYPED.items(), key=lambda kv: -kv[1]):
957
+ print(" %-30s metadata said %-24s %d" % (kind, claimed[:24], hits))
958
+ if naming.UNKNOWN:
959
+ print("\nwords still missing from terms.py: %s"
960
+ % ", ".join(sorted(naming.UNKNOWN)[:20]))
961
+
962
+
963
+ if __name__ == "__main__":
964
+ main()