feat: add 5 application-build & object tools (SP21 coverage, phase 3)
application_build (generate_code/rebuild/clean), check_online_change, move_object, get_signature_crc, set_exclude_from_build. API per SP21 ScriptApplication.pyi / ScriptObject.pyi. Plan: docs/superpowers/plans/2026-06-12-sp21-api-coverage.md (phase 3).
This commit is contained in:
parent
fb7d886a33
commit
44a58ff2bf
7 changed files with 400 additions and 0 deletions
41
src/scripts/application_build_action.py
Normal file
41
src/scripts/application_build_action.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import sys, scriptengine as script_engine, os, traceback
|
||||
|
||||
ACTION = "{ACTION}"
|
||||
|
||||
try:
|
||||
print("DEBUG: application_build_action script: Action='%s', Project='%s'" % (ACTION, PROJECT_FILE_PATH))
|
||||
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
||||
action = ACTION.lower().strip()
|
||||
if action not in ('generate_code', 'rebuild', 'clean'):
|
||||
raise ValueError("Invalid action '%s'. Must be 'generate_code', 'rebuild' or 'clean'." % ACTION)
|
||||
|
||||
target_app = None
|
||||
try:
|
||||
target_app = primary_project.active_application
|
||||
except Exception as e:
|
||||
print("WARN: Could not get active application: %s. Searching..." % e)
|
||||
if not target_app:
|
||||
for child in primary_project.get_children(True):
|
||||
if hasattr(child, 'is_application') and child.is_application and hasattr(child, 'build'):
|
||||
target_app = child
|
||||
break
|
||||
if not target_app:
|
||||
raise RuntimeError("No application found in project.")
|
||||
app_name = getattr(target_app, 'get_name', lambda: "Unknown")()
|
||||
|
||||
if not hasattr(target_app, action):
|
||||
raise TypeError("Application '%s' does not support %s()." % (app_name, action))
|
||||
print("DEBUG: Calling %s() on app '%s'..." % (action, app_name))
|
||||
getattr(target_app, action)()
|
||||
print("DEBUG: %s executed." % action)
|
||||
|
||||
print("Action: %s" % action)
|
||||
print("Application: %s" % app_name)
|
||||
print("SCRIPT_SUCCESS: %s executed for application '%s'. Use get_compile_messages for details." % (action, app_name))
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
detailed_error = traceback.format_exc()
|
||||
error_message = "Error executing %s for project %s: %s\n%s" % (ACTION, PROJECT_FILE_PATH, e, detailed_error)
|
||||
print(error_message)
|
||||
print("SCRIPT_ERROR: %s" % error_message)
|
||||
sys.exit(1)
|
||||
29
src/scripts/check_online_change.py
Normal file
29
src/scripts/check_online_change.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import sys, scriptengine as script_engine, os, traceback
|
||||
|
||||
try:
|
||||
print("DEBUG: check_online_change script: Project='%s'" % PROJECT_FILE_PATH)
|
||||
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
||||
|
||||
target_app = None
|
||||
try:
|
||||
target_app = primary_project.active_application
|
||||
except Exception as e:
|
||||
print("WARN: Could not get active application: %s" % e)
|
||||
if not target_app:
|
||||
raise RuntimeError("No active application found in project.")
|
||||
app_name = getattr(target_app, 'get_name', lambda: "Unknown")()
|
||||
|
||||
if not hasattr(target_app, 'is_online_change_possible'):
|
||||
raise TypeError("is_online_change_possible is not available on this SP (needs 3.5.10.0+).")
|
||||
possible = target_app.is_online_change_possible()
|
||||
|
||||
print("Application: %s" % app_name)
|
||||
print("Online Change Possible: %s" % possible)
|
||||
print("SCRIPT_SUCCESS: Online change check done.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
detailed_error = traceback.format_exc()
|
||||
error_message = "Error checking online change for project %s: %s\n%s" % (PROJECT_FILE_PATH, e, detailed_error)
|
||||
print(error_message)
|
||||
print("SCRIPT_ERROR: %s" % error_message)
|
||||
sys.exit(1)
|
||||
38
src/scripts/get_signature_crc.py
Normal file
38
src/scripts/get_signature_crc.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import sys, scriptengine as script_engine, os, traceback
|
||||
|
||||
OBJECT_PATH = "{OBJECT_PATH}"
|
||||
|
||||
try:
|
||||
print("DEBUG: get_signature_crc script: Object='%s', Project='%s'" % (OBJECT_PATH, PROJECT_FILE_PATH))
|
||||
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
||||
if not OBJECT_PATH:
|
||||
raise ValueError("Object path empty.")
|
||||
|
||||
target_object = find_object_by_path_robust(primary_project, OBJECT_PATH, "target POU")
|
||||
if not target_object:
|
||||
raise ValueError("Object not found at path: %s" % OBJECT_PATH)
|
||||
obj_name = getattr(target_object, 'get_name', lambda: OBJECT_PATH)()
|
||||
|
||||
if not hasattr(target_object, 'get_signature_crc'):
|
||||
raise TypeError("Object '%s' does not support get_signature_crc()." % obj_name)
|
||||
|
||||
# Needs a successful build first (compile_project). Parent application
|
||||
# is found automatically when omitted.
|
||||
crc = target_object.get_signature_crc()
|
||||
if crc is None:
|
||||
raise RuntimeError(
|
||||
"Signature CRC is None for '%s' -- the application probably has not been "
|
||||
"built yet. Run compile_project first." % obj_name)
|
||||
|
||||
# CRC may be an IronPython long -- print with %s, never json.
|
||||
print("Object: %s" % obj_name)
|
||||
print("Signature CRC: %s" % crc)
|
||||
print("SCRIPT_SUCCESS: Signature CRC read.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
detailed_error = traceback.format_exc()
|
||||
error_message = "Error reading signature CRC for '%s' in project %s: %s\n%s" % (
|
||||
OBJECT_PATH, PROJECT_FILE_PATH, e, detailed_error)
|
||||
print(error_message)
|
||||
print("SCRIPT_ERROR: %s" % error_message)
|
||||
sys.exit(1)
|
||||
43
src/scripts/move_object.py
Normal file
43
src/scripts/move_object.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import sys, scriptengine as script_engine, os, traceback
|
||||
|
||||
OBJECT_PATH = "{OBJECT_PATH}"
|
||||
NEW_PARENT_PATH = "{NEW_PARENT_PATH}"
|
||||
NEW_INDEX = {NEW_INDEX}
|
||||
|
||||
try:
|
||||
print("DEBUG: move_object script: Object='%s', NewParent='%s', Index=%s, Project='%s'" % (
|
||||
OBJECT_PATH, NEW_PARENT_PATH, NEW_INDEX, PROJECT_FILE_PATH))
|
||||
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
||||
if not OBJECT_PATH:
|
||||
raise ValueError("Object path empty.")
|
||||
|
||||
target_object = find_object_by_path_robust(primary_project, OBJECT_PATH, "object to move")
|
||||
if not target_object:
|
||||
raise ValueError("Object not found at path: %s" % OBJECT_PATH)
|
||||
|
||||
if NEW_PARENT_PATH:
|
||||
new_parent = find_object_by_path_robust(primary_project, NEW_PARENT_PATH, "new parent")
|
||||
if not new_parent:
|
||||
raise ValueError("New parent not found at path: %s" % NEW_PARENT_PATH)
|
||||
parent_name = getattr(new_parent, 'get_name', lambda: NEW_PARENT_PATH)()
|
||||
else:
|
||||
# Empty parent path = move to project top level.
|
||||
new_parent = primary_project
|
||||
parent_name = "<project root>"
|
||||
|
||||
target_object.move(new_parent, NEW_INDEX)
|
||||
primary_project.save()
|
||||
print("DEBUG: move + save OK")
|
||||
|
||||
print("Moved: %s" % OBJECT_PATH)
|
||||
print("New Parent: %s" % parent_name)
|
||||
print("Index: %s" % NEW_INDEX)
|
||||
print("SCRIPT_SUCCESS: Object moved. Project saved.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
detailed_error = traceback.format_exc()
|
||||
error_message = "Error moving object '%s' in project %s: %s\n%s" % (
|
||||
OBJECT_PATH, PROJECT_FILE_PATH, e, detailed_error)
|
||||
print(error_message)
|
||||
print("SCRIPT_ERROR: %s" % error_message)
|
||||
sys.exit(1)
|
||||
45
src/scripts/set_exclude_from_build.py
Normal file
45
src/scripts/set_exclude_from_build.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import sys, scriptengine as script_engine, os, traceback
|
||||
|
||||
OBJECT_PATH = "{OBJECT_PATH}"
|
||||
EXCLUDE = {EXCLUDE}
|
||||
|
||||
try:
|
||||
print("DEBUG: set_exclude_from_build script: Object='%s', Exclude=%s, Project='%s'" % (
|
||||
OBJECT_PATH, EXCLUDE, PROJECT_FILE_PATH))
|
||||
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
||||
if not OBJECT_PATH:
|
||||
raise ValueError("Object path empty.")
|
||||
|
||||
target_object = find_object_by_path_robust(primary_project, OBJECT_PATH, "target object")
|
||||
if not target_object:
|
||||
raise ValueError("Object not found at path: %s" % OBJECT_PATH)
|
||||
obj_name = getattr(target_object, 'get_name', lambda: OBJECT_PATH)()
|
||||
|
||||
try:
|
||||
if not target_object.exclude_from_build_is_valid:
|
||||
raise TypeError("exclude_from_build is not valid for object '%s' (type %s)." % (
|
||||
obj_name, type(target_object).__name__))
|
||||
except AttributeError:
|
||||
print("DEBUG: exclude_from_build_is_valid not available; trying setter directly.")
|
||||
|
||||
target_object.exclude_from_build = EXCLUDE
|
||||
primary_project.save()
|
||||
|
||||
effective = "unknown"
|
||||
try:
|
||||
effective = str(target_object.effectively_excluded_from_build)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Object: %s" % obj_name)
|
||||
print("Exclude From Build: %s" % EXCLUDE)
|
||||
print("Effectively Excluded: %s" % effective)
|
||||
print("SCRIPT_SUCCESS: exclude_from_build set. Project saved.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
detailed_error = traceback.format_exc()
|
||||
error_message = "Error setting exclude_from_build for '%s' in project %s: %s\n%s" % (
|
||||
OBJECT_PATH, PROJECT_FILE_PATH, e, detailed_error)
|
||||
print(error_message)
|
||||
print("SCRIPT_ERROR: %s" % error_message)
|
||||
sys.exit(1)
|
||||
129
src/server.ts
129
src/server.ts
|
|
@ -2686,6 +2686,135 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
}
|
||||
);
|
||||
|
||||
// ─── Application Build & Object Tools (SP21 coverage phase 3) ────────
|
||||
// API: SP21 ScriptApplication.pyi / ScriptObject.pyi.
|
||||
|
||||
s.tool(
|
||||
'application_build',
|
||||
"Runs a build action on the active application: 'generate_code' (full code generation, what F11 does), 'rebuild' (clean + build), or 'clean' (remove compile info for this application). For a plain incremental build use compile_project. Check results with get_compile_messages.",
|
||||
{
|
||||
projectFilePath: z.string().describe("Path to the project file."),
|
||||
action: z.enum(['generate_code', 'rebuild', 'clean']).describe("Build action to run."),
|
||||
},
|
||||
async (args: { projectFilePath: string; action: 'generate_code' | 'rebuild' | 'clean' }) => {
|
||||
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
||||
const script = scriptManager.prepareScriptWithHelpers(
|
||||
'application_build_action',
|
||||
{ PROJECT_FILE_PATH: escaped, ACTION: args.action },
|
||||
['ensure_project_open']
|
||||
);
|
||||
const result = await executor.executeScript(script, 300_000);
|
||||
return formatToolResponse(result, `${args.action} executed. Use get_compile_messages for details.`);
|
||||
}
|
||||
);
|
||||
|
||||
s.tool(
|
||||
'check_online_change',
|
||||
"Checks whether an ONLINE CHANGE is currently possible for the active application (app.is_online_change_possible) — i.e. whether download_to_device would do an online change instead of a full download. Read-only.",
|
||||
{
|
||||
projectFilePath: z.string().describe("Path to the project file."),
|
||||
},
|
||||
async (args: { projectFilePath: string }) => {
|
||||
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
||||
const script = scriptManager.prepareScriptWithHelpers(
|
||||
'check_online_change',
|
||||
{ PROJECT_FILE_PATH: escaped },
|
||||
['ensure_project_open']
|
||||
);
|
||||
const result = await executor.executeScript(script);
|
||||
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
|
||||
if (!success) {
|
||||
return formatToolResponse(result, '');
|
||||
}
|
||||
const m = result.output.match(/Online Change Possible:\s*(.+)/);
|
||||
return { content: [{ type: 'text' as const, text: `Online change possible: ${m ? m[1].trim() : 'unknown'}` }], isError: false };
|
||||
}
|
||||
);
|
||||
|
||||
s.tool(
|
||||
'move_object',
|
||||
"Moves an object to a new parent in the project tree (obj.move) and saves. Pass an empty/omitted newParentPath to move to the project top level.",
|
||||
{
|
||||
projectFilePath: z.string().describe("Path to the project file."),
|
||||
objectPath: z.string().describe("Path of the object to move (e.g. 'Application/MyPOU')."),
|
||||
newParentPath: z.string().optional().describe("Path of the new parent (e.g. 'Application/Folder1'). Omit for project top level."),
|
||||
newIndex: z.number().int().optional().describe("Index within the new parent. Default -1 (append)."),
|
||||
},
|
||||
async (args: { projectFilePath: string; objectPath: string; newParentPath?: string; newIndex?: number }) => {
|
||||
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
||||
const script = scriptManager.prepareScriptWithHelpers(
|
||||
'move_object',
|
||||
{
|
||||
PROJECT_FILE_PATH: escaped,
|
||||
OBJECT_PATH: sanitizePouPath(args.objectPath),
|
||||
NEW_PARENT_PATH: args.newParentPath ? sanitizePouPath(args.newParentPath) : '',
|
||||
NEW_INDEX: String(args.newIndex ?? -1),
|
||||
},
|
||||
['ensure_project_open', 'find_object_by_path']
|
||||
);
|
||||
const result = await executor.executeScript(script);
|
||||
return await formatModifyingResponse(
|
||||
result,
|
||||
`Object '${args.objectPath}' moved to '${args.newParentPath || '<project root>'}'. Project saved.`,
|
||||
escaped,
|
||||
mirrorCtx
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
s.tool(
|
||||
'get_signature_crc',
|
||||
"Reads the signature CRC of a POU (obj.get_signature_crc) — changes when the POU's public interface changes, useful for API-compatibility checks. Requires a successful build first (compile_project). Read-only.",
|
||||
{
|
||||
projectFilePath: z.string().describe("Path to the project file."),
|
||||
objectPath: z.string().describe("Path of the POU (e.g. 'Application/MyFB')."),
|
||||
},
|
||||
async (args: { projectFilePath: string; objectPath: string }) => {
|
||||
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
||||
const script = scriptManager.prepareScriptWithHelpers(
|
||||
'get_signature_crc',
|
||||
{ PROJECT_FILE_PATH: escaped, OBJECT_PATH: sanitizePouPath(args.objectPath) },
|
||||
['ensure_project_open', 'find_object_by_path']
|
||||
);
|
||||
const result = await executor.executeScript(script);
|
||||
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
|
||||
if (!success) {
|
||||
return formatToolResponse(result, '');
|
||||
}
|
||||
const m = result.output.match(/Signature CRC:\s*(.+)/);
|
||||
return { content: [{ type: 'text' as const, text: `${args.objectPath} signature CRC: ${m ? m[1].trim() : 'unknown'}` }], isError: false };
|
||||
}
|
||||
);
|
||||
|
||||
s.tool(
|
||||
'set_exclude_from_build',
|
||||
"Sets or clears the 'Exclude from build' flag on an object (obj.exclude_from_build) and saves. Excluded objects are ignored by the compiler. Note a parent's true value overrides a child's false.",
|
||||
{
|
||||
projectFilePath: z.string().describe("Path to the project file."),
|
||||
objectPath: z.string().describe("Path of the object (e.g. 'Application/TestPOU')."),
|
||||
exclude: z.boolean().describe("true = exclude from build; false = include."),
|
||||
},
|
||||
async (args: { projectFilePath: string; objectPath: string; exclude: boolean }) => {
|
||||
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
||||
const script = scriptManager.prepareScriptWithHelpers(
|
||||
'set_exclude_from_build',
|
||||
{
|
||||
PROJECT_FILE_PATH: escaped,
|
||||
OBJECT_PATH: sanitizePouPath(args.objectPath),
|
||||
EXCLUDE: pyBool(args.exclude),
|
||||
},
|
||||
['ensure_project_open', 'find_object_by_path']
|
||||
);
|
||||
const result = await executor.executeScript(script);
|
||||
return await formatModifyingResponse(
|
||||
result,
|
||||
`exclude_from_build=${args.exclude} set on '${args.objectPath}'. Project saved.`,
|
||||
escaped,
|
||||
mirrorCtx
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Extract a JSON block between marker lines and pretty-print it; if no
|
||||
// markers found, return the raw output. Used by the device tools so the
|
||||
// agent actually sees the scan results / reachability candidates.
|
||||
|
|
|
|||
75
tests/integration/build-object-tools-prep.test.ts
Normal file
75
tests/integration/build-object-tools-prep.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import { ScriptManager } from '../../src/script-manager';
|
||||
|
||||
/**
|
||||
* Script-preparation tests for the SP21-coverage phase 3 application build
|
||||
* & object tools. No CODESYS required.
|
||||
*/
|
||||
describe('E2E Script Preparation — build & object tools (SP21 coverage phase 3)', () => {
|
||||
const scriptsDir = path.join(__dirname, '..', '..', 'src', 'scripts');
|
||||
const mgr = new ScriptManager(scriptsDir);
|
||||
const P = { PROJECT_FILE_PATH: 'C:\\test.project' };
|
||||
|
||||
it('application_build_action prepares all three actions', () => {
|
||||
for (const action of ['generate_code', 'rebuild', 'clean']) {
|
||||
const script = mgr.prepareScriptWithHelpers(
|
||||
'application_build_action', { ...P, ACTION: action }, ['ensure_project_open']
|
||||
);
|
||||
expect(script).toContain(`ACTION = "${action}"`);
|
||||
expect(script).toContain('active_application');
|
||||
expect(script).toContain('SCRIPT_SUCCESS');
|
||||
}
|
||||
});
|
||||
|
||||
it('check_online_change prepares', () => {
|
||||
const script = mgr.prepareScriptWithHelpers('check_online_change', P, ['ensure_project_open']);
|
||||
expect(script).toContain('is_online_change_possible');
|
||||
expect(script).toContain('SCRIPT_SUCCESS');
|
||||
});
|
||||
|
||||
it('move_object prepares with parent and index', () => {
|
||||
const script = mgr.prepareScriptWithHelpers(
|
||||
'move_object',
|
||||
{ ...P, OBJECT_PATH: 'Application/MyPOU', NEW_PARENT_PATH: 'Application/Folder1', NEW_INDEX: '-1' },
|
||||
['ensure_project_open', 'find_object_by_path']
|
||||
);
|
||||
expect(script).toContain('def find_object_by_path_robust');
|
||||
expect(script).toContain('NEW_INDEX = -1');
|
||||
expect(script).toContain('.move(');
|
||||
expect(script).toContain('SCRIPT_SUCCESS');
|
||||
});
|
||||
|
||||
it('get_signature_crc prepares', () => {
|
||||
const script = mgr.prepareScriptWithHelpers(
|
||||
'get_signature_crc',
|
||||
{ ...P, OBJECT_PATH: 'Application/MyFB' },
|
||||
['ensure_project_open', 'find_object_by_path']
|
||||
);
|
||||
expect(script).toContain('get_signature_crc');
|
||||
expect(script).toContain('SCRIPT_SUCCESS');
|
||||
});
|
||||
|
||||
it('set_exclude_from_build prepares with flag', () => {
|
||||
const script = mgr.prepareScriptWithHelpers(
|
||||
'set_exclude_from_build',
|
||||
{ ...P, OBJECT_PATH: 'Application/TestPOU', EXCLUDE: 'True' },
|
||||
['ensure_project_open', 'find_object_by_path']
|
||||
);
|
||||
expect(script).toContain('EXCLUDE = True');
|
||||
expect(script).toContain('exclude_from_build');
|
||||
expect(script).toContain('SCRIPT_SUCCESS');
|
||||
});
|
||||
|
||||
it('all phase-3 scripts are ASCII-only (IronPython 2.7 constraint)', () => {
|
||||
const names = [
|
||||
'application_build_action', 'check_online_change', 'move_object',
|
||||
'get_signature_crc', 'set_exclude_from_build',
|
||||
];
|
||||
for (const name of names) {
|
||||
const content = mgr.loadTemplate(name);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
expect(/^[\x00-\x7F]*$/.test(content), `${name}.py must be ASCII-only`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue