@dforge-core/dforge-mcp 0.2.45 → 0.2.47

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 (83) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -1
  3. package/dist/server.js +71 -3
  4. package/package.json +3 -3
  5. package/resources/docs/dsl-reference.md +1 -1
  6. package/resources/schemas/data-views.schema.json +50 -0
  7. package/scripts/install-skills.mjs +1 -0
  8. package/skills/dforge-1c-import/SKILL.md +375 -0
  9. package/skills/dforge-1c-import/references/environment.md +98 -0
  10. package/skills/dforge-1c-import/references/loading.md +164 -0
  11. package/skills/dforge-1c-import/references/measurements.md +210 -0
  12. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  13. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  14. package/skills/dforge-1c-import/scripts/README.md +164 -0
  15. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  16. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  17. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  18. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  19. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  20. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  21. package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
  22. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  23. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  24. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  25. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  26. package/skills/dforge-1c-import/scripts/lib/delta.py +770 -0
  27. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  28. package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
  29. package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
  30. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  31. package/skills/dforge-1c-import/scripts/lib/external.py +202 -0
  32. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  33. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  34. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  35. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  36. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  37. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  38. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  39. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  40. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  41. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  42. package/skills/dforge-1c-import/scripts/lib/meta.py +325 -0
  43. package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -0
  44. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  45. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  46. package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
  47. package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
  48. package/skills/dforge-1c-import/scripts/lib/peek.py +68 -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 +91 -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 +493 -0
  59. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  60. package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
  61. package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
  62. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  63. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  64. package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
  65. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  66. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  67. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  68. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  69. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  70. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  71. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  72. package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
  73. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  74. package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
  75. package/skills/dforge-mcp-author/SKILL.md +5 -0
  76. package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
  77. package/skills/dforge-mcp-author/references/data-views.md +38 -2
  78. package/skills/dforge-mcp-author/references/formulas.md +32 -1
  79. package/skills/dforge-mcp-author/references/print-templates.md +30 -5
  80. package/skills/dforge-mcp-author/references/queries.md +17 -2
  81. package/skills/dforge-mcp-author/references/registers.md +1 -0
  82. package/skills/dforge-mcp-author/references/reports.md +1 -1
  83. package/skills/dforge-mcp-author/references/validation-checklist.md +1 -0
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Module dependency graph, and whether it can be installed.
5
+
6
+ dForge installs modules in dependency order, so a cycle between two modules is not a
7
+ style problem - it is a package that cannot be installed at all. A cycle is broken with
8
+ an extension or a bridge module (see MODULE_CONVENTIONS.md), but first it has to be seen.
9
+
10
+ python tools/deps.py
11
+ """
12
+ from __future__ import print_function
13
+
14
+ import codecs
15
+ import collections
16
+ import json
17
+ import os
18
+
19
+ import paths
20
+
21
+ ROOT = paths.ROOT
22
+ MODULES = paths.MODULES
23
+
24
+
25
+ def load(path):
26
+ with codecs.open(path, "r", "utf-8") as f:
27
+ return json.load(f)
28
+
29
+
30
+ def graph():
31
+ out = collections.OrderedDict()
32
+ for code in sorted(os.listdir(MODULES)):
33
+ manifest = os.path.join(MODULES, code, "manifest.json")
34
+ if os.path.isfile(manifest):
35
+ out[code] = set(load(manifest).get("dependencies", {}))
36
+ return out
37
+
38
+
39
+ def referenced(code):
40
+ """Modules this one actually points at: `extends`, and every `entity` in a link,
41
+ reference or set. A dependency the manifest forgets to declare still binds the
42
+ install order - masterdata carried a lookup into shipping while declaring nothing,
43
+ and the pair was uninstallable in either order."""
44
+ out = set()
45
+
46
+ def walk(node):
47
+ if isinstance(node, dict):
48
+ for key, value in node.items():
49
+ if key in ("entity", "extends") and isinstance(value, str) and "." in value:
50
+ out.add(value.split(".", 1)[0])
51
+ else:
52
+ walk(value)
53
+ elif isinstance(node, list):
54
+ for item in node:
55
+ walk(item)
56
+
57
+ directory = os.path.join(MODULES, code, "entities")
58
+ if os.path.isdir(directory):
59
+ for name in sorted(os.listdir(directory)):
60
+ walk(load(os.path.join(directory, name)))
61
+ return out - set([code])
62
+
63
+
64
+ def cycles(edges):
65
+ """Every cycle reachable in the graph, as a list of module names."""
66
+ found, path, state = [], [], {}
67
+
68
+ def walk(node):
69
+ state[node] = "open"
70
+ path.append(node)
71
+ for target in sorted(edges.get(node, ())):
72
+ if target not in edges:
73
+ continue
74
+ if state.get(target) == "open":
75
+ found.append(path[path.index(target):] + [target])
76
+ elif state.get(target) != "done":
77
+ walk(target)
78
+ path.pop()
79
+ state[node] = "done"
80
+
81
+ for node in edges:
82
+ if state.get(node) != "done":
83
+ walk(node)
84
+ return found
85
+
86
+
87
+ def order(edges):
88
+ """Install order: a module after everything it depends on."""
89
+ done, out = set(), []
90
+ remaining = dict((k, set(v) & set(edges)) for k, v in edges.items())
91
+ while remaining:
92
+ ready = sorted(k for k, v in remaining.items() if v <= done)
93
+ if not ready:
94
+ return out, sorted(remaining)
95
+ for code in ready:
96
+ out.append(code)
97
+ done.add(code)
98
+ del remaining[code]
99
+ return out, []
100
+
101
+
102
+ def main():
103
+ edges = graph()
104
+ print("%-12s %s" % ("module", "depends on"))
105
+ print("-" * 60)
106
+ undeclared = collections.OrderedDict()
107
+ for code, deps in edges.items():
108
+ missing = sorted(referenced(code) - deps - set([code]))
109
+ if missing:
110
+ undeclared[code] = missing
111
+ deps.update(missing)
112
+ print("%-12s %s%s" % (code, ", ".join(sorted(deps)) or "-",
113
+ " (undeclared: %s)" % ", ".join(missing) if missing else ""))
114
+
115
+ if undeclared:
116
+ print("\n%d module(s) reference a module they do not declare - add the"
117
+ " dependency, or move the reference to the other side:" % len(undeclared))
118
+ for code, missing in undeclared.items():
119
+ print(" %s -> %s" % (code, ", ".join(missing)))
120
+
121
+ found = cycles(edges)
122
+ if found:
123
+ print("\n%d cycle(s) - these cannot be installed as they stand:" % len(found))
124
+ for cycle in found:
125
+ print(" %s" % " -> ".join(cycle))
126
+ else:
127
+ print("\nno cycles")
128
+
129
+ sequence, stuck = order(edges)
130
+ print("\ninstall order:\n %s" % " -> ".join(sequence))
131
+ if stuck:
132
+ print("stuck: %s" % ", ".join(stuck))
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """How much each document type is used: last date, last twelve months, posted.
4
+
5
+ Usage does not narrow what gets ported - a document nobody has posted in years is still
6
+ done - but it orders the work, and it says which types have a history to prove a port on.
7
+ A type with no posted documents has nothing for the oracle to compare against.
8
+
9
+ The twelve months are counted back from the newest document in the infobase, not from
10
+ today: an extraction is a snapshot, and "the last year" should mean the last year the
11
+ client actually worked in it.
12
+
13
+ python tools/1c.py docusage # the table, and out/docusage.json
14
+
15
+ Read-only. The physical table comes from `DBNames` by the object's GUID, never guessed.
16
+ """
17
+ from __future__ import print_function
18
+
19
+ import codecs
20
+ import io
21
+ import json
22
+ import os
23
+ import sys
24
+
25
+ import handlers
26
+ import meta
27
+ import migrate
28
+ import paths
29
+ import store
30
+
31
+ TARGET = os.path.join(paths.OUT, "docusage.json")
32
+
33
+
34
+ def documents():
35
+ """(name, physical table) for every document in the configuration."""
36
+ with io.open(os.path.join(paths.OUT, "objects.json"), encoding="utf-8") as f:
37
+ objects = json.load(f)
38
+ index = meta.dbnames_index()
39
+ found = []
40
+ for obj in objects:
41
+ if obj.get("family") != "Document":
42
+ continue
43
+ entry = index.get(obj["guid"])
44
+ if entry and entry["type"] == "Document":
45
+ found.append((obj["name"], entry["physical"]))
46
+ return sorted(found)
47
+
48
+
49
+ def measure(cur, table):
50
+ cur.execute("SELECT COUNT(*), MAX(_Date_Time) FROM %s" % store.quote(table))
51
+ rows, last = cur.fetchone()
52
+ return rows, last
53
+
54
+
55
+ def main():
56
+ migrate.load_env()
57
+ cur = migrate.source_connect().cursor()
58
+ docs = documents()
59
+ newest = None
60
+ raw = []
61
+ for name, table in docs:
62
+ rows, last = measure(cur, table)
63
+ raw.append((name, table, rows, last))
64
+ if last is not None and (newest is None or last > newest):
65
+ newest = last
66
+ cutoff = newest.replace(year=newest.year - 1) if newest else None
67
+ result = []
68
+ for name, table, rows, last in raw:
69
+ recent = posted = 0
70
+ if rows:
71
+ cur.execute("SELECT SUM(CASE WHEN _Date_Time >= ? THEN 1 ELSE 0 END),"
72
+ " SUM(CASE WHEN _Posted = %s THEN 1 ELSE 0 END) FROM %s"
73
+ % (store.literal(store.TRUE), store.quote(table)), cutoff)
74
+ recent, posted = [int(v or 0) for v in cur.fetchone()]
75
+ result.append({
76
+ "name": name,
77
+ "table": table,
78
+ "client": name.startswith(handlers.CLIENT),
79
+ "rows": rows,
80
+ "posted": posted,
81
+ "last12m": recent,
82
+ # 1C stores dates shifted by the year offset; the snapshot keeps them as stored.
83
+ "last": last.strftime("%Y-%m-%d") if last else None,
84
+ })
85
+ result.sort(key=lambda r: (-r["last12m"], -r["posted"], r["name"]))
86
+ with codecs.open(TARGET, "w", "utf-8") as f:
87
+ json.dump({"newest": newest.strftime("%Y-%m-%d") if newest else None,
88
+ "cutoff": cutoff.strftime("%Y-%m-%d") if cutoff else None,
89
+ "documents": result}, f, ensure_ascii=False, indent="\t")
90
+ out = codecs.getwriter("utf-8")(sys.stdout.buffer)
91
+ vendor = [r for r in result if not r["client"]]
92
+ out.write("%d document types, %d vendor; newest document %s, last 12 months from %s\n"
93
+ % (len(result), len(vendor), newest, cutoff))
94
+ out.write("vendor with rows %d, posted %d, used in last 12m %d\n\n" % (
95
+ sum(1 for r in vendor if r["rows"]), sum(1 for r in vendor if r["posted"]),
96
+ sum(1 for r in vendor if r["last12m"])))
97
+ for r in result:
98
+ if r["rows"]:
99
+ out.write("%-6s %-52s %9d %9d %8d %s\n" % (
100
+ "client" if r["client"] else "", r["name"], r["rows"], r["posted"],
101
+ r["last12m"], r["last"]))
102
+ out.write("\nwritten %s\n" % TARGET)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Export a 1C:Enterprise 8 infobase to `out/` *without* any 1C software - the one layer that
5
+ reads 1C itself. Everything past it reads the export, so it does not matter which kind of
6
+ infobase the export came from.
7
+
8
+ Two kinds of infobase, one export:
9
+
10
+ a server infobase on MS SQL DF1C_SERVER + DF1C_DB (source_mssql.py)
11
+ a file infobase, 1Cv8.1CD DF1C_FILE=<path to .1CD> (source_file.py, onecd.py)
12
+
13
+ What it writes:
14
+
15
+ config/<file>.txt|.bin the configuration, one file per Config row, inflated (extract)
16
+ dbnames.json DBNames: GUID -> physical table/column name (dbnames)
17
+ schema.json every table, its columns in MS SQL's terms, row count (schema)
18
+ data/<table>.parquet the rows of every `_` table, as 1C stores them (data)
19
+ export.json the manifest: which infobase, when, its year offset,
20
+ and per table the rows and the horizon taken before them
21
+
22
+ Everything needed to rebuild the data model lives in two service tables:
23
+ Params -> row FileName='DBNames': GUID -> physical table/column name map
24
+ Config -> the configuration itself, one row (8.2) or several PartNo rows (8.3) per file
25
+ File bodies are raw-deflate compressed (no zlib header), in both kinds of infobase.
26
+
27
+ The rows are written as 1C stores them - dates still carrying a server infobase's year
28
+ offset, booleans as binary(1) - and the manifest records the offset, so a project query
29
+ written against the infobase reads the export unchanged.
30
+
31
+ python 1c.py dump1c all # everything, in order
32
+ python 1c.py dump1c data _Reference12 ... # re-export these tables only
33
+ python 1c.py dump1c data --skip "ChngR|Settings"
34
+ python 1c.py dump1c data --changes # only what 1C changed since (MS SQL; changes.py)
35
+ python 1c.py dump1c probe # what the source is, before reading it
36
+ """
37
+ import argparse
38
+ import codecs
39
+ import datetime
40
+ import json
41
+ import os
42
+ import re
43
+ import struct
44
+ import sys
45
+ import time
46
+ import zlib
47
+
48
+ import paths
49
+
50
+ OUT = paths.OUT
51
+ DATA = os.path.join(OUT, "data")
52
+ MANIFEST = os.path.join(OUT, "export.json")
53
+ CHUNK = 50000
54
+
55
+ # What 1C keeps only as a consequence of other tables: register totals and the document
56
+ # journals. 53.7 M rows of a 313 M-row infobase, and no `_Version` to read their change by,
57
+ # so each `--changes` would read them all again - 14 minutes of a cutover window that has
58
+ # 10. Nothing loads them; `seedstate verify` reads totals, and says which to export.
59
+ DERIVED = re.compile(r"^_(AccumRgTn?|AccRgAT|AccRgCT|DocumentJournal)\d+$")
60
+
61
+
62
+ def inflate(data):
63
+ """1C stores file bodies as raw deflate; a few small ones are stored plain."""
64
+ if not data:
65
+ return b""
66
+ data = bytes(data)
67
+ for attempt in (lambda: zlib.decompressobj(-15).decompress(data), lambda: zlib.decompress(data)):
68
+ try:
69
+ return attempt()
70
+ except zlib.error:
71
+ continue
72
+ return data
73
+
74
+
75
+ def open_source():
76
+ """The infobase the environment names: a .1CD file, or a server infobase."""
77
+ path = os.environ.get("DF1C_FILE")
78
+ if path:
79
+ if os.environ.get("DF1C_SERVER") or os.environ.get("DF1C_DB"):
80
+ raise SystemExit("DF1C_FILE and DF1C_SERVER/DF1C_DB are both set - say which infobase to read")
81
+ if os.path.isdir(path):
82
+ path = os.path.join(path, "1Cv8.1CD")
83
+ import source_file
84
+ return source_file.FileSource(path)
85
+ import source_mssql
86
+ return source_mssql.MssqlSource(
87
+ paths.required("DF1C_SERVER", "which MS SQL server holds it (or DF1C_FILE for a file infobase)"),
88
+ paths.required("DF1C_DB", "which MS SQL infobase to read"))
89
+
90
+
91
+ # --------------------------------------------------------------------------- DBNames
92
+
93
+ DBNAME_ENTRY = re.compile(r'\{([0-9a-fA-F-]{36}),"([^"]*)",(\d+)\}')
94
+
95
+
96
+ def parse_dbnames(buf):
97
+ """8.2 writes DBNames as UTF-8 text: {ver, {count, {guid,"Kind",N}, ...}}."""
98
+ if buf.startswith(codecs.BOM_UTF8) or buf[:1] == b"{":
99
+ txt = buf.decode("utf-8-sig", "replace")
100
+ entries = [{"guid": g, "type": k, "number": int(n)} for g, k, n in DBNAME_ENTRY.findall(txt)]
101
+ if entries:
102
+ return entries
103
+ raise ValueError("text DBNames matched no entries")
104
+ return parse_dbnames_binary(buf)
105
+
106
+
107
+ def parse_dbnames_binary(buf):
108
+ """8.3 layout: <u32 count> then count * { <u32 chars> utf16le, <u32 chars> utf16le, <u32 n> }"""
109
+ out, pos = [], 0
110
+ (count,) = struct.unpack_from("<I", buf, pos)
111
+ pos += 4
112
+ if count == 0 or count > 500000:
113
+ raise ValueError("implausible entry count %d" % count)
114
+ for _ in range(count):
115
+ vals = []
116
+ for _ in range(2):
117
+ (ln,) = struct.unpack_from("<I", buf, pos)
118
+ pos += 4
119
+ vals.append(buf[pos:pos + ln * 2].decode("utf-16-le"))
120
+ pos += ln * 2
121
+ (num,) = struct.unpack_from("<I", buf, pos)
122
+ pos += 4
123
+ out.append({"guid": vals[0], "type": vals[1], "number": num})
124
+ return out
125
+
126
+
127
+ def physical_name(entry):
128
+ """DBNames kind + number -> the SQL object name 1C generated for it."""
129
+ kind, number = entry.get("type"), entry.get("number")
130
+ if not kind or number is None:
131
+ return None
132
+ return "_%s%d" % (kind, number)
133
+
134
+
135
+ # --------------------------------------------------------------------------- the manifest
136
+
137
+ def manifest():
138
+ if not os.path.exists(MANIFEST):
139
+ return None
140
+ with codecs.open(MANIFEST, "r", "utf-8") as f:
141
+ return json.load(f)
142
+
143
+
144
+ def begin(source):
145
+ """The manifest this run adds to. One export is one infobase: adding a second one's
146
+ tables to it would make a mix no check could tell from the real thing."""
147
+ described = source.describe()
148
+ held = manifest()
149
+ if held and held.get("source") != described:
150
+ raise SystemExit("out/ holds an export of %s, not %s.\n"
151
+ " Point DF1C_OUT somewhere else, or remove %s to start over."
152
+ % (json.dumps(held.get("source")), json.dumps(described), MANIFEST))
153
+ held = held or {"source": described, "tables": {}}
154
+ held["year_offset"] = source.year_offset
155
+ held["consistency"] = source.consistent
156
+ return held
157
+
158
+
159
+ def commit(held):
160
+ held["updated"] = datetime.datetime.now().replace(microsecond=0).isoformat()
161
+ write_json("export.json", held)
162
+
163
+
164
+ # --------------------------------------------------------------------------- commands
165
+
166
+ def cmd_databases(source, args):
167
+ if not hasattr(source, "databases"):
168
+ raise SystemExit("databases: a file infobase is the one file")
169
+ for name, state, access in source.databases():
170
+ print("%-44s %-10s access=%s" % (name, state, access))
171
+
172
+
173
+ def cmd_probe(source, args):
174
+ print(json.dumps(source.describe(), ensure_ascii=False))
175
+ print("year offset %d" % source.year_offset)
176
+ if hasattr(source, "probe"):
177
+ for line in source.probe():
178
+ print(line)
179
+ families = {}
180
+ for name, _, rows in source.tables():
181
+ if name.startswith("_"):
182
+ kind = re.match(r"^_([A-Za-z]+)", name).group(1)
183
+ families[kind] = families.get(kind, 0) + 1
184
+ print("\n physical table families:")
185
+ for kind, n in sorted(families.items(), key=lambda kv: -kv[1]):
186
+ print(" %-24s %d" % (kind, n))
187
+
188
+
189
+ def cmd_schema(source, args):
190
+ tables = source.tables()
191
+ write_json("schema.json", {"tables": [
192
+ {"name": name, "rows": rows, "columns": cols} for name, cols, rows in sorted(tables)
193
+ ]})
194
+ guessed = getattr(getattr(source, "names", None), "guessed", None)
195
+ if guessed:
196
+ held = begin(source)
197
+ held["names_guessed"] = sorted(guessed)
198
+ commit(held)
199
+ print("tables=%d" % len(tables))
200
+
201
+
202
+ def cmd_dbnames(source, args):
203
+ buf = source.read_file("Params", "DBNames")
204
+ save("dbnames.bin", buf)
205
+ entries = parse_dbnames(buf)
206
+ for entry in entries:
207
+ entry["physical"] = physical_name(entry)
208
+ write_json("dbnames.json", {"count": len(entries), "entries": entries})
209
+ kinds = {}
210
+ for entry in entries:
211
+ kinds[entry["type"]] = kinds.get(entry["type"], 0) + 1
212
+ print("entries=%d distinct kinds=%d" % (len(entries), len(kinds)))
213
+ for kind, n in sorted(kinds.items(), key=lambda kv: -kv[1]):
214
+ print(" %-28s %d" % (kind, n))
215
+
216
+
217
+ def cmd_extract(source, args):
218
+ target = os.path.join(OUT, "config")
219
+ if not os.path.isdir(target):
220
+ os.makedirs(target)
221
+ names = source.files("Config")
222
+ text = binary = 0
223
+ for i, name in enumerate(names):
224
+ buf = source.read_file("Config", name)
225
+ safe = re.sub(r"[^0-9A-Za-z._-]", "_", name)[:120]
226
+ if buf.startswith(codecs.BOM_UTF8) or b"{" in buf[:64]:
227
+ suffix, text = ".txt", text + 1
228
+ else:
229
+ suffix, binary = ".bin", binary + 1
230
+ with open(os.path.join(target, safe + suffix), "wb") as f:
231
+ f.write(buf)
232
+ if i and i % 2000 == 0:
233
+ sys.stderr.write("[i] %d/%d\n" % (i, len(names)))
234
+ print("files=%d text=%d binary=%d -> %s" % (len(names), text, binary, target))
235
+
236
+
237
+ def export_table(source, name, columns, expected=None):
238
+ """One table's rows, in full, into `data/<name>.parquet`. The number written."""
239
+ import pyarrow.parquet as pq
240
+ if not os.path.isdir(DATA):
241
+ os.makedirs(DATA)
242
+ arrow = arrow_schema(columns)
243
+ tmp = os.path.join(DATA, name + ".parquet.tmp")
244
+ writer = pq.ParquetWriter(tmp, arrow, compression="zstd")
245
+ written = 0
246
+ try:
247
+ for batch in source.rows(name, [c["column"] for c in columns], CHUNK):
248
+ writer.write_table(arrow_batch(arrow, columns, batch))
249
+ written += len(batch)
250
+ if (expected or 0) > CHUNK:
251
+ sys.stderr.write("\r %-30s %9d / %d" % (name, written, expected))
252
+ finally:
253
+ writer.close()
254
+ if (expected or 0) > CHUNK:
255
+ sys.stderr.write("\n")
256
+ os.replace(tmp, os.path.join(DATA, name + ".parquet"))
257
+ return written
258
+
259
+
260
+ def cmd_data(source, args):
261
+ held = begin(source)
262
+ schema = dict((name, (cols, rows)) for name, cols, rows in source.tables())
263
+ if args.changes:
264
+ import changes
265
+ try:
266
+ changes.run(source, held, schema, args,
267
+ lambda name, columns: export_table(source, name, columns, schema[name][1]))
268
+ finally:
269
+ commit(held)
270
+ return
271
+ if args.since:
272
+ raise SystemExit("data: --since goes with --changes")
273
+ named = list(args.tables)
274
+ if args.objects:
275
+ # Every catalog, document and chart - what `delta` reads the keys of. Small: 407
276
+ # tables and 1.6 M rows of a 313 M-row infobase.
277
+ import changes
278
+ objects = [n for n in sorted(schema) if n not in named
279
+ and changes.classify(n, schema[n][0], schema)[0] == "object"]
280
+ if not objects:
281
+ # A file infobase: no rowversion, so nothing is an object in that sense - and
282
+ # an empty list would otherwise mean "every table".
283
+ raise SystemExit("data --objects: no table here has a rowversion - this is for an"
284
+ " MS SQL export, which `delta` needs")
285
+ named += objects
286
+ args.tables = named
287
+ wanted = args.tables or sorted(n for n in schema if n.startswith("_")
288
+ and (args.derived or not DERIVED.match(n)))
289
+ if not args.tables and not args.derived:
290
+ left = [n for n in schema if DERIVED.match(n)]
291
+ print("left out %d table(s) 1C derives from others - totals, journals (%d rows);"
292
+ " name one, or --derived, to export it" % (len(left), sum(schema[n][1] or 0 for n in left)))
293
+ unknown = [t for t in wanted if t not in schema]
294
+ if unknown:
295
+ raise SystemExit("data: no such table: %s" % ", ".join(unknown))
296
+ if args.tables:
297
+ # A partial export still takes every enum: they are a few rows each, and the loader
298
+ # decodes an enum column by reading its table - left out, a module's load stops at
299
+ # its first enum with "_Enum727 does not exist".
300
+ enums = [n for n in sorted(schema) if re.match(r"^_Enum\d+$", n) and n not in wanted]
301
+ wanted += enums
302
+ if args.skip:
303
+ wanted = [t for t in wanted if not re.search(args.skip, t)]
304
+ started, total = time.time(), 0
305
+ for at, name in enumerate(wanted):
306
+ columns, expected = schema[name]
307
+ horizon = source.horizon()
308
+ written = export_table(source, name, columns, expected)
309
+ held["tables"][name] = {"rows": written,
310
+ "horizon": horizon.hex() if horizon is not None else None,
311
+ "exported": datetime.datetime.now().replace(microsecond=0).isoformat()}
312
+ total += written
313
+ # The manifest is written as the tables are, so an export stopped half-way says
314
+ # truthfully what it holds.
315
+ if at % 50 == 0 or at == len(wanted) - 1:
316
+ commit(held)
317
+ print("tables=%d rows=%d in %.0fs -> %s" % (len(wanted), total, time.time() - started, DATA))
318
+
319
+
320
+ def cmd_all(source, args):
321
+ for step in (cmd_dbnames, cmd_schema, cmd_extract, cmd_data):
322
+ step(source, args)
323
+
324
+
325
+ # --------------------------------------------------------------------------- parquet
326
+
327
+ def arrow_type(column):
328
+ """The schema dump's MS SQL type -> how the export stores it."""
329
+ import pyarrow as pa
330
+ kind = column["type"]
331
+ if kind in ("binary", "varbinary", "image", "timestamp", "rowversion"):
332
+ return pa.binary()
333
+ if kind in ("nchar", "nvarchar", "ntext", "char", "varchar", "text", "sysname", "uniqueidentifier"):
334
+ return pa.string()
335
+ if kind in ("numeric", "decimal"):
336
+ return pa.decimal128(column.get("precision") or 38, column.get("scale") or 0)
337
+ if kind in ("datetime", "datetime2", "smalldatetime", "date"):
338
+ return pa.timestamp("us")
339
+ if kind in ("int", "smallint", "tinyint", "bigint"):
340
+ return pa.int64()
341
+ if kind == "bit":
342
+ return pa.bool_()
343
+ if kind in ("float", "real"):
344
+ return pa.float64()
345
+ return pa.string()
346
+
347
+
348
+ def arrow_schema(columns):
349
+ import pyarrow as pa
350
+ return pa.schema([pa.field(c["column"], arrow_type(c)) for c in columns])
351
+
352
+
353
+ def arrow_batch(arrow, columns, batch):
354
+ import pyarrow as pa
355
+ arrays = []
356
+ for i, field in enumerate(arrow):
357
+ values = [row[i] for row in batch]
358
+ if pa.types.is_string(field.type):
359
+ values = [v if v is None or isinstance(v, str) else str(v) for v in values]
360
+ elif pa.types.is_binary(field.type):
361
+ values = [v if v is None else bytes(v) for v in values]
362
+ arrays.append(pa.array(values, type=field.type))
363
+ return pa.Table.from_arrays(arrays, schema=arrow)
364
+
365
+
366
+ def save(name, data):
367
+ if not os.path.isdir(OUT):
368
+ os.makedirs(OUT)
369
+ with open(os.path.join(OUT, name), "wb") as f:
370
+ f.write(data)
371
+
372
+
373
+ def write_json(name, doc):
374
+ if not os.path.isdir(OUT):
375
+ os.makedirs(OUT)
376
+ with codecs.open(os.path.join(OUT, name), "w", "utf-8") as f:
377
+ json.dump(doc, f, ensure_ascii=False, indent="\t")
378
+
379
+
380
+ COMMANDS = {
381
+ "databases": cmd_databases,
382
+ "probe": cmd_probe,
383
+ "schema": cmd_schema,
384
+ "dbnames": cmd_dbnames,
385
+ "extract": cmd_extract,
386
+ "data": cmd_data,
387
+ "all": cmd_all,
388
+ }
389
+
390
+
391
+ def main():
392
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
393
+ ap.add_argument("command", choices=sorted(COMMANDS))
394
+ ap.add_argument("tables", nargs="*", help="data: only these tables")
395
+ ap.add_argument("--skip", help="data: leave out tables matching this regex")
396
+ ap.add_argument("--objects", action="store_true",
397
+ help="data: every object table (catalogs, documents, charts) besides the named ones")
398
+ ap.add_argument("--derived", action="store_true",
399
+ help="data: export totals and journals too (left out by default)")
400
+ ap.add_argument("--changes", action="store_true",
401
+ help="data: read only what 1C changed since the export (MS SQL; see changes.py)")
402
+ ap.add_argument("--since", help="data --changes: from this day (YYYY-MM-DD) or rowversion (0x...)")
403
+ args = ap.parse_args()
404
+ COMMANDS[args.command](open_source(), args)
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()