@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.
- package/CHANGELOG.md +45 -0
- package/README.md +2 -1
- package/dist/server.js +71 -3
- package/package.json +3 -3
- package/resources/docs/dsl-reference.md +1 -1
- package/resources/schemas/data-views.schema.json +50 -0
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +375 -0
- package/skills/dforge-1c-import/references/environment.md +98 -0
- package/skills/dforge-1c-import/references/loading.md +164 -0
- package/skills/dforge-1c-import/references/measurements.md +210 -0
- package/skills/dforge-1c-import/references/print-forms.md +148 -0
- package/skills/dforge-1c-import/scripts/1c.py +178 -0
- package/skills/dforge-1c-import/scripts/README.md +164 -0
- package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
- package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
- package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
- package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
- package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
- package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
- package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
- package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
- package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
- package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +770 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
- package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
- package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
- package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
- package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
- package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
- package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/meta.py +325 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -0
- package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
- package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +68 -0
- package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
- package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
- package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
- package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
- package/skills/dforge-1c-import/scripts/lib/recorders.py +91 -0
- package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
- package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
- package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +493 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
- package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
- package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
- package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
- package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
- package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
- package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
- package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
- package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
- package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
- package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
- package/skills/dforge-mcp-author/SKILL.md +5 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
- package/skills/dforge-mcp-author/references/data-views.md +38 -2
- package/skills/dforge-mcp-author/references/formulas.md +32 -1
- package/skills/dforge-mcp-author/references/print-templates.md +30 -5
- package/skills/dforge-mcp-author/references/queries.md +17 -2
- package/skills/dforge-mcp-author/references/registers.md +1 -0
- package/skills/dforge-mcp-author/references/reports.md +1 -1
- 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()
|