d365fo-agent-developer 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ """Local tooling for D365 F&O corpus curation and metadata extraction."""
2
+
@@ -0,0 +1,147 @@
1
+ """Extract AOT table relations — the D365 equivalent of foreign keys.
2
+
3
+ AX business tables define no SQL foreign keys; the relational graph lives in the AOT
4
+ metadata: every ``AxTable`` (and ``AxTableExtension``) XML carries a ``<Relations>`` block
5
+ with the related table, the relationship type (Association/Composition), both cardinalities,
6
+ and the EXACT join fields (``<AxTableRelationConstraintField>``: Field ↔ RelatedField, plus
7
+ fixed-value constraints). This module walks one or more corpus roots (PackagesLocalDirectory
8
+ and/or editable source trees), parses those blocks, and stores them next to the SQL data
9
+ model so ``find_relations``/``get_sql_model`` can explain table relationships with the same
10
+ authority a foreign key would. Standard library only.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sqlite3
16
+ import xml.etree.ElementTree as ET
17
+ from pathlib import Path
18
+ from typing import Iterator
19
+
20
+ _XSI_TYPE = "{http://www.w3.org/2001/XMLSchema-instance}type"
21
+ # Mirrors index_store._NON_AOT_DIRS: build outputs that contain compiled XML copies.
22
+ _SKIP_DIRS = {"bin", "Descriptor", "Resources", "XppMetadata", "AdditionalFiles", "obj"}
23
+
24
+ _SCHEMA = """
25
+ CREATE TABLE IF NOT EXISTS aot_relations(
26
+ table_name TEXT, relation_name TEXT, related_table TEXT,
27
+ relationship_type TEXT, cardinality TEXT, related_cardinality TEXT,
28
+ edt_relation INTEGER, source_element TEXT,
29
+ PRIMARY KEY(table_name, relation_name, source_element));
30
+ CREATE TABLE IF NOT EXISTS aot_relation_fields(
31
+ table_name TEXT, relation_name TEXT, kind TEXT,
32
+ field TEXT, related_field TEXT, fixed_value TEXT, source_edt TEXT);
33
+ CREATE INDEX IF NOT EXISTS ix_aot_rel_table ON aot_relations(table_name);
34
+ CREATE INDEX IF NOT EXISTS ix_aot_rel_related ON aot_relations(related_table);
35
+ """
36
+
37
+
38
+ def _text(node: ET.Element | None) -> str | None:
39
+ return node.text.strip() if node is not None and node.text else None
40
+
41
+
42
+ def _iter_table_files(root: Path) -> Iterator[tuple[Path, str]]:
43
+ """Yield (xml_path, element_kind) for every AxTable/AxTableExtension under a corpus root."""
44
+ for kind in ("AxTable", "AxTableExtension"):
45
+ for type_dir in root.rglob(kind):
46
+ if not type_dir.is_dir() or type_dir.name != kind:
47
+ continue
48
+ if any(part in _SKIP_DIRS for part in type_dir.relative_to(root).parts):
49
+ continue
50
+ for xml_file in type_dir.glob("*.xml"):
51
+ yield xml_file, kind
52
+
53
+
54
+ def parse_table_relations(xml_path: Path, kind: str) -> tuple[str, str, list[dict[str, object]]]:
55
+ """Parse one AxTable/AxTableExtension XML. Returns (table_name, source_element, relations).
56
+
57
+ For an extension ``CustTable.MyModel``, the relations belong to ``CustTable``.
58
+ """
59
+ tree = ET.parse(xml_path)
60
+ root = tree.getroot()
61
+ element_name = _text(root.find("Name")) or xml_path.stem
62
+ table_name = element_name.split(".")[0] if kind == "AxTableExtension" else element_name
63
+
64
+ relations: list[dict[str, object]] = []
65
+ for rel in root.iter("AxTableRelation"):
66
+ constraints: list[dict[str, object]] = []
67
+ for constraint in rel.iter("AxTableRelationConstraint"):
68
+ ctype = (constraint.get(_XSI_TYPE) or "AxTableRelationConstraintField")
69
+ constraints.append({
70
+ "kind": ctype.replace("AxTableRelationConstraint", "").lower() or "field",
71
+ "field": _text(constraint.find("Field")),
72
+ "related_field": _text(constraint.find("RelatedField")),
73
+ "fixed_value": _text(constraint.find("Value")),
74
+ "source_edt": _text(constraint.find("SourceEDT")),
75
+ })
76
+ relations.append({
77
+ "name": _text(rel.find("Name")),
78
+ "related_table": _text(rel.find("RelatedTable")),
79
+ "relationship_type": _text(rel.find("RelationshipType")),
80
+ "cardinality": _text(rel.find("Cardinality")),
81
+ "related_cardinality": _text(rel.find("RelatedTableCardinality")),
82
+ "edt_relation": 1 if _text(rel.find("EDTRelation")) == "Yes" else 0,
83
+ "constraints": constraints,
84
+ })
85
+ return table_name, element_name, relations
86
+
87
+
88
+ def extract_aot_relations(
89
+ roots: "list[str | Path]",
90
+ db_path: str | Path,
91
+ *,
92
+ progress: "callable[[str, int], None] | None" = None,
93
+ ) -> dict[str, int]:
94
+ """Walk every AxTable/AxTableExtension under ``roots`` and persist their relations."""
95
+ conn = sqlite3.connect(Path(db_path))
96
+ conn.executescript("DELETE FROM aot_relations; DELETE FROM aot_relation_fields;"
97
+ if conn.execute("SELECT 1 FROM sqlite_master WHERE name='aot_relations'").fetchone()
98
+ else "SELECT 1;")
99
+ conn.executescript(_SCHEMA)
100
+
101
+ files = relations_count = 0
102
+ rel_rows: list[tuple] = []
103
+ field_rows: list[tuple] = []
104
+ for root in roots:
105
+ root = Path(root)
106
+ for xml_file, kind in _iter_table_files(root):
107
+ try:
108
+ table, element, relations = parse_table_relations(xml_file, kind)
109
+ except OSError:
110
+ # Windows MAX_PATH: deep AOT paths exceed 260 chars — retry extended-length.
111
+ try:
112
+ table, element, relations = parse_table_relations(
113
+ Path("\\\\?\\" + str(xml_file.resolve())), kind)
114
+ except (OSError, ET.ParseError):
115
+ continue
116
+ except ET.ParseError:
117
+ continue
118
+ files += 1
119
+ for rel in relations:
120
+ if not rel["related_table"]:
121
+ continue
122
+ relations_count += 1
123
+ rel_rows.append((table, rel["name"], rel["related_table"],
124
+ rel["relationship_type"], rel["cardinality"],
125
+ rel["related_cardinality"], rel["edt_relation"], element))
126
+ for c in rel["constraints"]:
127
+ field_rows.append((table, rel["name"], c["kind"], c["field"],
128
+ c["related_field"], c["fixed_value"], c["source_edt"]))
129
+ if len(rel_rows) >= 5000:
130
+ conn.executemany("INSERT OR REPLACE INTO aot_relations VALUES(?,?,?,?,?,?,?,?)", rel_rows)
131
+ conn.executemany("INSERT INTO aot_relation_fields VALUES(?,?,?,?,?,?,?)", field_rows)
132
+ conn.commit()
133
+ rel_rows, field_rows = [], []
134
+ if progress:
135
+ progress(str(root), relations_count)
136
+ conn.executemany("INSERT OR REPLACE INTO aot_relations VALUES(?,?,?,?,?,?,?,?)", rel_rows)
137
+ conn.executemany("INSERT INTO aot_relation_fields VALUES(?,?,?,?,?,?,?)", field_rows)
138
+ conn.commit()
139
+ stats = {
140
+ "files_parsed": files,
141
+ "relations": conn.execute("SELECT COUNT(*) FROM aot_relations").fetchone()[0],
142
+ "constraint_fields": conn.execute("SELECT COUNT(*) FROM aot_relation_fields").fetchone()[0],
143
+ "tables_with_relations": conn.execute(
144
+ "SELECT COUNT(DISTINCT table_name) FROM aot_relations").fetchone()[0],
145
+ }
146
+ conn.close()
147
+ return stats
d365fo_agent/build.py ADDED
@@ -0,0 +1,285 @@
1
+ """Build / compile adapters — the top rungs of the verification ladder.
2
+
3
+ Two backends:
4
+
5
+ * :class:`BuildRunner` plans (or runs) an **MSBuild** of a Visual Studio D365 project. Kept for the
6
+ full-IDE flow; ``execute=False`` only emits the command.
7
+ * :class:`XppCompiler` drives the standalone **X++ compiler** (``xppc.exe``) directly. This is the
8
+ one that actually closes the "does it compile?" rung WITHOUT a full AOS/Visual Studio: ``xppc.exe``
9
+ ships in ``PackagesLocalDirectory/bin`` and compiles a single model against the package metadata,
10
+ emitting a diagnostics log we parse into structured errors/warnings. It is Windows-only (the
11
+ compiler is a .NET Framework assembly) and degrades gracefully — :meth:`XppCompiler.available`
12
+ is False when ``xppc.exe`` is not on this host, so callers report "compile rung needs a Windows
13
+ D365 host" instead of crashing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import subprocess
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class BuildResult:
26
+ status: str
27
+ command: list[str]
28
+ returncode: int | None = None
29
+ stdout: str = ""
30
+ stderr: str = ""
31
+
32
+
33
+ class BuildRunner:
34
+ def __init__(self, msbuild_executable: str = "msbuild.exe") -> None:
35
+ self.msbuild_executable = msbuild_executable
36
+
37
+ def build_project(
38
+ self,
39
+ project_path: str | Path,
40
+ *,
41
+ execute: bool = False,
42
+ output_path: str | Path | None = None,
43
+ additional_properties: dict[str, str] | None = None,
44
+ ) -> BuildResult:
45
+ project_path = Path(project_path)
46
+ properties = dict(additional_properties or {})
47
+ if output_path is not None:
48
+ properties["OutputPath"] = str(Path(output_path))
49
+ command = [self.msbuild_executable, str(project_path)]
50
+ command.extend(f"/p:{key}={value}" for key, value in properties.items())
51
+ if not execute:
52
+ return BuildResult(status="planned", command=command)
53
+
54
+ process = subprocess.run(command, capture_output=True, text=True, check=False)
55
+ return BuildResult(
56
+ status="succeeded" if process.returncode == 0 else "failed",
57
+ command=command,
58
+ returncode=process.returncode,
59
+ stdout=process.stdout,
60
+ stderr=process.stderr,
61
+ )
62
+
63
+
64
+ # --- X++ compiler (xppc.exe) --------------------------------------------------------
65
+
66
+ @dataclass(slots=True)
67
+ class Diagnostic:
68
+ severity: str # "error" | "warning"
69
+ category: str # e.g. "Compile", "ExternalReference", "Compile Fatal"
70
+ message: str
71
+ element: str | None = None # dynamics://… element path, when present
72
+ location: str | None = None # "(line,col),(line,col)", when present
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class CompileResult:
77
+ status: str # "succeeded" | "failed" | "unavailable"
78
+ model: str
79
+ returncode: int | None = None
80
+ error_count: int = 0
81
+ warning_count: int = 0
82
+ diagnostics: list[Diagnostic] = field(default_factory=list)
83
+ log_path: str | None = None
84
+ command: list[str] = field(default_factory=list)
85
+ message: str | None = None
86
+
87
+ def to_dict(self) -> dict[str, object]:
88
+ return {
89
+ "status": self.status,
90
+ "model": self.model,
91
+ "returncode": self.returncode,
92
+ "error_count": self.error_count,
93
+ "warning_count": self.warning_count,
94
+ "diagnostics": [
95
+ {"severity": d.severity, "category": d.category, "message": d.message,
96
+ "element": d.element, "location": d.location}
97
+ for d in self.diagnostics
98
+ ],
99
+ "log_path": self.log_path,
100
+ "command": self.command,
101
+ "message": self.message,
102
+ }
103
+
104
+
105
+ _SUMMARY_RE = re.compile(r"^(Errors|Warnings):\s*(\d+)\s*$")
106
+ # "<Category words> <Severity>: <rest>" — Severity ∈ {Fatal Error, Error, Warning}
107
+ _DIAG_RE = re.compile(r"^(?P<category>.*?)\b(?P<sev>Fatal Error|Error|Warning):\s*(?P<rest>.*)$")
108
+ _ELEMENT_RE = re.compile(r"(dynamics://\S+?)(?=[:\s]|$)")
109
+ _LOCATION_RE = re.compile(r"\[(\([\d]+,[\d]+\),\([\d]+,[\d]+\))\]")
110
+
111
+
112
+ def parse_xppc_log(text: str) -> dict[str, object]:
113
+ """Parse an ``xppc.exe`` ``-log`` file into structured diagnostics.
114
+
115
+ The log groups diagnostics between ``===`` separators, each ``<Category> <Severity>: <msg>``,
116
+ then a ``Errors: N`` / ``Warnings: N`` summary. Returns
117
+ ``{error_count, warning_count, diagnostics:[Diagnostic]}``. When the summary lines are absent
118
+ we fall back to counting parsed diagnostics so callers always get a usable count.
119
+ """
120
+ diagnostics: list[Diagnostic] = []
121
+ summary_errors: int | None = None
122
+ summary_warnings: int | None = None
123
+
124
+ for raw in text.replace("\x00", "").splitlines():
125
+ line = raw.strip()
126
+ if not line or set(line) <= {"="}:
127
+ continue
128
+ summary = _SUMMARY_RE.match(line)
129
+ if summary:
130
+ if summary.group(1) == "Errors":
131
+ summary_errors = int(summary.group(2))
132
+ else:
133
+ summary_warnings = int(summary.group(2))
134
+ continue
135
+ diag = _DIAG_RE.match(line)
136
+ if not diag:
137
+ continue
138
+ sev_word = diag.group("sev")
139
+ rest = diag.group("rest").strip()
140
+ severity = "error" if "Error" in sev_word else "warning"
141
+ category = (diag.group("category").strip() + (" Fatal" if sev_word == "Fatal Error" else "")).strip() or "Compile"
142
+ element_match = _ELEMENT_RE.search(rest)
143
+ location_match = _LOCATION_RE.search(rest)
144
+ diagnostics.append(Diagnostic(
145
+ severity=severity,
146
+ category=category,
147
+ message=rest,
148
+ element=element_match.group(1) if element_match else None,
149
+ location=location_match.group(1) if location_match else None,
150
+ ))
151
+
152
+ error_count = summary_errors if summary_errors is not None else sum(1 for d in diagnostics if d.severity == "error")
153
+ warning_count = summary_warnings if summary_warnings is not None else sum(1 for d in diagnostics if d.severity == "warning")
154
+ return {"error_count": error_count, "warning_count": warning_count, "diagnostics": diagnostics}
155
+
156
+
157
+ class XppCompiler:
158
+ """Drive ``xppc.exe`` to compile a single model against the package metadata."""
159
+
160
+ def __init__(self, packages_root: str | Path, xppc_path: str | Path | None = None) -> None:
161
+ self.packages_root = Path(packages_root)
162
+ self.xppc_path = Path(xppc_path) if xppc_path else (self.packages_root / "bin" / "xppc.exe")
163
+
164
+ def available(self) -> bool:
165
+ """True iff the X++ compiler is present on this host (it is Windows-only)."""
166
+ return self.xppc_path.exists()
167
+
168
+ def compile_model(
169
+ self,
170
+ model: str,
171
+ *,
172
+ output_path: str | Path,
173
+ log_path: str | Path,
174
+ reference_folder: str | Path | None = None,
175
+ appchecker: bool = False,
176
+ xref_file: str | Path | None = None,
177
+ timeout: int = 1800,
178
+ ) -> CompileResult:
179
+ """Compile ``model`` and return structured diagnostics.
180
+
181
+ ``appchecker=True`` additionally runs the Appchecker (best-practice) rules. Returns a
182
+ ``CompileResult`` with ``status="unavailable"`` (not an exception) when ``xppc.exe`` is not
183
+ on this host, so the caller can report the rung as "needs a Windows D365 host".
184
+ """
185
+ if not self.available():
186
+ return CompileResult(
187
+ status="unavailable", model=model,
188
+ message=f"xppc.exe not found at {self.xppc_path} — the compile rung needs a Windows D365 host "
189
+ "with PackagesLocalDirectory/bin present.",
190
+ )
191
+ output_path = Path(output_path)
192
+ log_path = Path(log_path)
193
+ output_path.mkdir(parents=True, exist_ok=True)
194
+ log_path.parent.mkdir(parents=True, exist_ok=True)
195
+ reference_folder = Path(reference_folder) if reference_folder else self.packages_root
196
+
197
+ command = [
198
+ str(self.xppc_path),
199
+ f"-metadata={self.packages_root}",
200
+ f"-modelmodule={model}",
201
+ f"-output={output_path}",
202
+ f"-referenceFolder={reference_folder}",
203
+ f"-log={log_path}",
204
+ ]
205
+ if appchecker:
206
+ command.append("-RunAppcheckerRules")
207
+ if xref_file:
208
+ command.extend(["-xref", f"-xreffile={Path(xref_file)}"])
209
+
210
+ try:
211
+ process = subprocess.run(command, capture_output=True, text=True, check=False, timeout=timeout)
212
+ except subprocess.TimeoutExpired:
213
+ return CompileResult(status="failed", model=model, command=command,
214
+ message=f"xppc.exe timed out after {timeout}s.")
215
+
216
+ parsed = {"error_count": 0, "warning_count": 0, "diagnostics": []}
217
+ if log_path.exists():
218
+ parsed = parse_xppc_log(log_path.read_text(encoding="utf-8", errors="ignore"))
219
+ error_count = int(parsed["error_count"])
220
+ # xppc returns non-zero on failure; treat a clean log + zero exit as success.
221
+ status = "succeeded" if (process.returncode == 0 and error_count == 0) else "failed"
222
+ return CompileResult(
223
+ status=status, model=model, returncode=process.returncode,
224
+ error_count=error_count, warning_count=int(parsed["warning_count"]),
225
+ diagnostics=list(parsed["diagnostics"]), log_path=str(log_path), command=command,
226
+ message=(process.stdout.strip().splitlines()[-1] if process.stdout.strip() else None),
227
+ )
228
+
229
+ def compile_overlay(
230
+ self,
231
+ model: str,
232
+ overlays: list[tuple[str, str]],
233
+ *,
234
+ output_path: str | Path,
235
+ log_path: str | Path,
236
+ appchecker: bool = False,
237
+ timeout: int = 1800,
238
+ ) -> CompileResult:
239
+ """Compile freshly-generated artifacts IN CONTEXT — closes the generate->compile loop.
240
+
241
+ ``overlays`` is a list of ``(relative_path, content)`` where ``relative_path`` is under the
242
+ PackagesLocalDirectory (e.g. ``BABGeneralLedger/BABGeneralLedger/AxClass/Foo.xml``). Each is
243
+ temporarily written into the PLD so the model compiles WITH the new artifact, then the PLD is
244
+ **always restored** (added files removed, overwritten files put back) via try/finally — even
245
+ on error/timeout. This proves generated X++ actually compiles before it is claimed done.
246
+ """
247
+ if not self.available():
248
+ return CompileResult(
249
+ status="unavailable", model=model,
250
+ message=f"xppc.exe not found at {self.xppc_path} — the compile rung needs a Windows D365 host.",
251
+ )
252
+ if not overlays:
253
+ return CompileResult(status="failed", model=model, message="No artifacts to overlay/compile.")
254
+ backups: list[tuple[Path, bytes | None]] = []
255
+ try:
256
+ for relative_path, content in overlays:
257
+ target = self.packages_root / relative_path
258
+ target.parent.mkdir(parents=True, exist_ok=True)
259
+ backups.append((target, target.read_bytes() if target.exists() else None))
260
+ target.write_text(content, encoding="utf-8")
261
+ # xppc ALSO (re)writes compiled metadata under <package>/XppMetadata/<model>/... as a
262
+ # byproduct; snapshot it so the finally block removes/restores it too (it does not yet
263
+ # exist for a brand-new artifact), leaving the PLD exactly as we found it.
264
+ meta = self._xppmetadata_path(relative_path)
265
+ if meta is not None:
266
+ backups.append((meta, meta.read_bytes() if meta.exists() else None))
267
+ result = self.compile_model(model, output_path=output_path, log_path=log_path, timeout=timeout, appchecker=appchecker)
268
+ result.message = (result.message or "") + f" [compiled with {len(overlays)} overlaid artifact(s), PLD restored]"
269
+ return result
270
+ finally:
271
+ for target, original in reversed(backups): # restore in reverse order
272
+ if original is None:
273
+ if target.exists():
274
+ target.unlink()
275
+ else:
276
+ target.write_bytes(original)
277
+
278
+ def _xppmetadata_path(self, relative_path: str) -> Path | None:
279
+ """The compiled-metadata counterpart xppc writes for a source artifact: a source at
280
+ ``<package>/<model>/<AxType>/<name>.xml`` maps to ``<package>/XppMetadata/<model>/<AxType>/<name>.xml``."""
281
+ parts = relative_path.replace("\\", "/").split("/")
282
+ if len(parts) < 4:
283
+ return None
284
+ package, model = parts[0], parts[1]
285
+ return self.packages_root.joinpath(package, "XppMetadata", model, *parts[2:])