feat(remove_library): new MCP tool wrapping ScriptLibManObject.remove_library (#5)
Adds remove_library as the symmetric counterpart to add_library.
Script (src/scripts/remove_library.py):
- IronPython 2.7 / ASCII-only; no f-strings, no KeyboardInterrupt in
except-Exception clauses.
- Placeholder substitution: LIBRARY_NAME (bare) and LIBRARY_FQN_OR_NAME
(bare or "Name, Version (Company)").
- Locates the project Library Manager via the same three-step discovery
used in add_library.py: has_library_manager/get_library_manager on the
project, first-level child walk, then find("Library Manager") fallback.
- Pre-check: walks lm.references using _ref_name_matches logic (handles
placeholder "#Name" and managed "Name, Version (Company)" forms).
If the library is not referenced the script exits SCRIPT_SUCCESS with
the "Library Not Present:" marker -- idempotent, same convention as
add_library's dedup no-op branch.
- If found: calls lm.remove_library(existing_name) per the SP22 stub
(ScriptLibManObject.pyi: remove_library(name: str)), confirms removal
from lm.references, then project.save().
- Emits SCRIPT_SUCCESS or SCRIPT_ERROR with traceback on exception.
Server (src/server.ts):
- Tool registered immediately after add_library (~line 2067).
- Reads "Library Not Present:" marker to pick idempotent vs removed
wording -- same marker-driven pattern as add_library's dedup wording.
- Thin surface: projectFilePath + libraryName (required) +
libraryFqnOrName (optional, for multi-version disambiguation).
Tests (tests/integration/e2e.test.ts):
- Template-prep test asserts: no leftover {PLACEHOLDER}s, substituted
values present, remove_library call present, references walk present,
"Library Not Present" marker present, SCRIPT_SUCCESS/SCRIPT_ERROR
markers present.
- All 22 tests pass.
References:
- helpme-codesys.com scripting engine > ScriptLibManObject
- SP22 stub: Stubs/scriptengine/ScriptLibManObject.pyi (remove_library
at line 455, references property at line 464)
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
This commit is contained in:
parent
0962f93118
commit
05d0e37e21
3 changed files with 234 additions and 0 deletions
169
src/scripts/remove_library.py
Normal file
169
src/scripts/remove_library.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import sys, scriptengine as script_engine, os, traceback
|
||||
|
||||
# RTFM (helpme-codesys.com "ScriptLibManObject" + local SP22 stub
|
||||
# Stubs/scriptengine/ScriptLibManObject.pyi):
|
||||
#
|
||||
# - ScriptLibManObject.remove_library(name: str) removes a reference by
|
||||
# name. The name argument accepts either the bare library name (e.g.
|
||||
# "Standard") or the fully-qualified "Name, Version (Company)" form.
|
||||
# - lm.references gives back ScriptLibraryReference items. Placeholder
|
||||
# refs have .name == "#<Name>"; managed refs have .name ==
|
||||
# "<Name>, <Version> (<Company>)".
|
||||
# - The project-level libman is obtained the same way add_library.py does:
|
||||
# container.has_library_manager / container.get_library_manager(), then
|
||||
# first-level-child walk, then find("Library Manager") fallback.
|
||||
|
||||
LIBRARY_NAME = "{LIBRARY_NAME}"
|
||||
LIBRARY_FQN_OR_NAME = "{LIBRARY_FQN_OR_NAME}"
|
||||
|
||||
|
||||
def _ref_name_matches(ref_name, target):
|
||||
"""Match a reference name against a bare or fully-qualified target.
|
||||
A managed ref shows up as 'Name, Version (Company)'; a placeholder
|
||||
shows up as '#Name'. Either target form is accepted."""
|
||||
if ref_name is None:
|
||||
return False
|
||||
if ref_name == target:
|
||||
return True
|
||||
if ref_name == ('#' + target):
|
||||
return True
|
||||
# Managed: leading 'Name, ...'
|
||||
if ref_name.startswith(target + ','):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _find_reference(lm, target):
|
||||
"""Walk lm.references and return the first entry whose name matches
|
||||
target (bare or fully-qualified), or None."""
|
||||
try:
|
||||
refs = lm.references
|
||||
except Exception as e:
|
||||
print("DEBUG: lm.references unavailable: %s" % e)
|
||||
return None
|
||||
if refs is None:
|
||||
return None
|
||||
for r in refs:
|
||||
try:
|
||||
rn = getattr(r, 'name', None)
|
||||
except Exception:
|
||||
rn = None
|
||||
if _ref_name_matches(rn, target):
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
print("DEBUG: remove_library script: Library='%s', FQN='%s', Project='%s'"
|
||||
% (LIBRARY_NAME, LIBRARY_FQN_OR_NAME, PROJECT_FILE_PATH))
|
||||
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
||||
if not LIBRARY_NAME:
|
||||
raise ValueError("Library name empty.")
|
||||
|
||||
project_name = os.path.basename(PROJECT_FILE_PATH)
|
||||
|
||||
# Locate the project's Library Manager. Mirror the discovery logic from
|
||||
# add_library.py: container API first, then child walk, then name search.
|
||||
lib_manager = None
|
||||
try:
|
||||
if hasattr(primary_project, 'has_library_manager') and primary_project.has_library_manager:
|
||||
lib_manager = primary_project.get_library_manager()
|
||||
print("DEBUG: Found Library Manager via project.get_library_manager()")
|
||||
except Exception as e:
|
||||
print("DEBUG: project.get_library_manager() failed: %s" % e)
|
||||
|
||||
if not lib_manager:
|
||||
try:
|
||||
for child in primary_project.get_children(False):
|
||||
try:
|
||||
if getattr(child, 'has_library_manager', False):
|
||||
lib_manager = child.get_library_manager()
|
||||
if lib_manager is not None:
|
||||
print("DEBUG: Found Library Manager under '%s'" % child.get_name())
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print("DEBUG: walking children for libman failed: %s" % e)
|
||||
|
||||
if not lib_manager:
|
||||
try:
|
||||
found_list = primary_project.find("Library Manager", True)
|
||||
if found_list:
|
||||
lib_manager = found_list[0]
|
||||
print("DEBUG: Found Library Manager via find('Library Manager') fallback")
|
||||
except Exception as e:
|
||||
print("DEBUG: find('Library Manager') failed: %s" % e)
|
||||
|
||||
if not lib_manager:
|
||||
raise RuntimeError("Library Manager not found in project '%s'." % project_name)
|
||||
|
||||
print("DEBUG: Library Manager found: %s" % getattr(lib_manager, 'get_name', lambda: '?')())
|
||||
|
||||
# Pre-check: is the library actually referenced? Walk lm.references for
|
||||
# either the bare name or the fully-qualified name.
|
||||
# Check bare LIBRARY_NAME first; fall back to LIBRARY_FQN_OR_NAME if
|
||||
# it differs (caller may pass the "Name, Version (Company)" form).
|
||||
match_target = LIBRARY_NAME
|
||||
existing_ref = _find_reference(lib_manager, match_target)
|
||||
if existing_ref is None and LIBRARY_FQN_OR_NAME and LIBRARY_FQN_OR_NAME != LIBRARY_NAME:
|
||||
match_target = LIBRARY_FQN_OR_NAME
|
||||
existing_ref = _find_reference(lib_manager, match_target)
|
||||
|
||||
if existing_ref is None:
|
||||
msg = ("Library '%s' is not referenced in this project. Nothing to remove."
|
||||
% LIBRARY_NAME)
|
||||
print(msg)
|
||||
print("Library Not Present: %s" % LIBRARY_NAME)
|
||||
print("Project: %s" % project_name)
|
||||
print("SCRIPT_SUCCESS: %s" % msg)
|
||||
sys.exit(0)
|
||||
|
||||
existing_name = getattr(existing_ref, 'name', '?')
|
||||
is_ph = bool(getattr(existing_ref, 'is_placeholder', False))
|
||||
kind = "placeholder" if is_ph else "managed"
|
||||
print("DEBUG: Found reference to remove -- name=%r, kind=%s" % (existing_name, kind))
|
||||
|
||||
# Verify remove_library is exposed (may not exist on very old SPs).
|
||||
if not hasattr(lib_manager, 'remove_library'):
|
||||
raise RuntimeError(
|
||||
"lm.remove_library() is not available on this CODESYS SP. "
|
||||
"Cannot remove library '%s' via script." % LIBRARY_NAME)
|
||||
|
||||
# Remove using the name the script API already knows about (the name
|
||||
# from lm.references, which is always the form remove_library accepts).
|
||||
lib_manager.remove_library(existing_name)
|
||||
print("DEBUG: remove_library('%s') returned without exception." % existing_name)
|
||||
|
||||
# Verify removal succeeded by re-checking lm.references.
|
||||
still_present = _find_reference(lib_manager, LIBRARY_NAME)
|
||||
if still_present is not None:
|
||||
raise RuntimeError(
|
||||
"remove_library('%s') returned without error but the reference "
|
||||
"is still present in lm.references. Project NOT saved." % existing_name)
|
||||
|
||||
# Save only after confirmed removal.
|
||||
try:
|
||||
primary_project.save()
|
||||
print("DEBUG: Project saved successfully after removing library.")
|
||||
except Exception as save_err:
|
||||
detailed_error = traceback.format_exc()
|
||||
error_message = ("Error saving project after removing library '%s': %s\n%s"
|
||||
% (LIBRARY_NAME, save_err, detailed_error))
|
||||
print(error_message)
|
||||
print("SCRIPT_ERROR: %s" % error_message)
|
||||
sys.exit(1)
|
||||
|
||||
print("Library Removed: %s" % LIBRARY_NAME)
|
||||
print("Project: %s" % project_name)
|
||||
print("SCRIPT_SUCCESS: Library '%s' (%s, ref-name=%r) removed from project '%s'."
|
||||
% (LIBRARY_NAME, kind, existing_name, project_name))
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
detailed_error = traceback.format_exc()
|
||||
error_message = ("Error removing library '%s' from project '%s': %s\n%s"
|
||||
% (LIBRARY_NAME, PROJECT_FILE_PATH, e, detailed_error))
|
||||
print(error_message)
|
||||
print("SCRIPT_ERROR: %s" % error_message)
|
||||
sys.exit(1)
|
||||
|
|
@ -2077,6 +2077,44 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
}
|
||||
);
|
||||
|
||||
s.tool(
|
||||
'remove_library',
|
||||
"Removes a library reference from the CODESYS project's Library Manager. Idempotent: if the named library is not currently referenced, the tool succeeds with a no-op confirmation rather than an error. Accepts either the bare library name (e.g. 'Standard') or the fully-qualified 'Name, Version (Company)' form. Verifies removal in lm.references before saving. Per helpme-codesys.com ScriptLibManObject docs and the local SP22 stub Stubs/scriptengine/ScriptLibManObject.pyi.",
|
||||
{
|
||||
projectFilePath: z.string().describe("Path to the project file."),
|
||||
libraryName: z.string().describe("Bare name of the library to remove (e.g. 'Standard', 'Util'). Must match a reference currently present in the project's Library Manager."),
|
||||
libraryFqnOrName: z.string().optional().describe("Optional fully-qualified name 'Name, Version (Company)' to target a specific version when multiple references with the same bare name exist. Falls back to libraryName when omitted."),
|
||||
},
|
||||
async (args: { projectFilePath: string; libraryName: string; libraryFqnOrName?: string }) => {
|
||||
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
||||
const fqn = (args.libraryFqnOrName || args.libraryName).trim();
|
||||
const script = scriptManager.prepareScriptWithHelpers(
|
||||
'remove_library',
|
||||
{
|
||||
PROJECT_FILE_PATH: escaped,
|
||||
LIBRARY_NAME: args.libraryName.trim(),
|
||||
LIBRARY_FQN_OR_NAME: fqn,
|
||||
},
|
||||
['ensure_project_open']
|
||||
);
|
||||
const blocked = await gateOpForTool({
|
||||
enabled: !!config.approveEdits,
|
||||
slug: `remove-library-${args.libraryName.replace(/[^A-Za-z0-9._-]+/g, '_')}`,
|
||||
oldText: `(* remove Library *)\nname: ${args.libraryName}\nproject: ${args.projectFilePath}\n`,
|
||||
newText: '',
|
||||
});
|
||||
if (blocked) return blocked;
|
||||
const result = await executor.executeScript(script);
|
||||
// Script emits "Library Not Present:" on the idempotent no-op path
|
||||
// and "Library Removed:" on actual removal.
|
||||
const noopHit = result.output.includes('Library Not Present:');
|
||||
const successMessage = noopHit
|
||||
? `Library '${args.libraryName}' not referenced in ${args.projectFilePath}. No-op.`
|
||||
: `Library '${args.libraryName}' removed from ${args.projectFilePath}. Project saved.`;
|
||||
return await formatModifyingResponse(result, successMessage, escaped, mirrorCtx);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Symbol Configuration Tools ───────────────────────────────────────
|
||||
//
|
||||
// Wraps ScriptSymbolConfigObject (since CODESYS 3.5.10.0). The Symbol
|
||||
|
|
|
|||
|
|
@ -148,6 +148,33 @@ describe('E2E Script Preparation', () => {
|
|||
expect(script).not.toMatch(/\{[A-Z_]+\}/);
|
||||
});
|
||||
|
||||
it('remove_library script renders without leftover placeholders and contains required markers', () => {
|
||||
const script = mgr.prepareScriptWithHelpers(
|
||||
'remove_library',
|
||||
{
|
||||
PROJECT_FILE_PATH: 'C:\\test.project',
|
||||
LIBRARY_NAME: 'Standard',
|
||||
LIBRARY_FQN_OR_NAME: 'Standard, 3.5.17.0 (System)',
|
||||
},
|
||||
['ensure_project_open']
|
||||
);
|
||||
// Placeholders must all be substituted
|
||||
expect(script).not.toMatch(/\{[A-Z_]+\}/);
|
||||
// Substituted values must appear
|
||||
expect(script).toContain('LIBRARY_NAME = "Standard"');
|
||||
expect(script).toContain('LIBRARY_FQN_OR_NAME = "Standard, 3.5.17.0 (System)"');
|
||||
// Core SP22 API call
|
||||
expect(script).toContain('lm.remove_library' || 'remove_library');
|
||||
expect(script).toContain('remove_library');
|
||||
// references walk must be present (pre-check)
|
||||
expect(script).toContain('references');
|
||||
// Idempotent no-op marker
|
||||
expect(script).toContain('Library Not Present');
|
||||
// Success and error markers
|
||||
expect(script).toContain('SCRIPT_SUCCESS');
|
||||
expect(script).toContain('SCRIPT_ERROR');
|
||||
});
|
||||
|
||||
it('check_status script has no placeholders after load', () => {
|
||||
const script = mgr.loadTemplate('check_status');
|
||||
// check_status has no {PLACEHOLDER} params
|
||||
|
|
|
|||
Loading…
Reference in a new issue