Three changes after deeper investigation against PLATEA Win V3: 1. The 'symbol' := 'read' attribute experiment didn't help. Reverted bump_project_version's GVL template to plain VAR_GLOBAL + qualified_only (matches what shipped originally, minus CONSTANT). Comment updated to explain both why CONSTANT is wrong (compile-time inlining) and why the symbol attribute alone wasn't enough (it requires a Symbol Configuration object to do anything). 2. Real root cause for read_running_version_online's 'Invalid expression': CODESYS strips unreferenced GVLs from the online symbol table at compile time, regardless of attribute pragmas. The version anchor by definition has no IEC code reading it, so the optimizer drops it. GVL_Test.bRun reads fine despite no references because GVL_Test has OTHER referenced variables; entire-GVL retention seems to be the stripping unit, not per-variable. Verified end-to-end: adding 'sVersionTag := _MCP_PROJECT_VERSION .sVersion;' in PLC_PRG made the read return '1.4.1.0' on PLATEA. 3. Updated the read_running_version_online error message to surface BOTH the (now-rare) CONSTANT case AND the (common) unreferenced-GVL case, with the exact 2-line code snippet a user needs to paste into their main program. The bump tool intentionally does NOT auto-inject this -- modifying user code on every release was deemed too invasive. Documented the requirement in TEST_OVERVIEW.md alongside the v5 sweep notes. 37/37 tests still green. v1.4.2.0 of MCPTest2 carries the working PLC_PRG reference as the canonical demonstration.
319 lines
14 KiB
Python
319 lines
14 KiB
Python
import sys, scriptengine as script_engine, traceback, re
|
|
|
|
# Bumps one part of the 4-part Project Information.version field of the
|
|
# primary project. Convention (per CODESYS / 3S / WAGO library practice):
|
|
#
|
|
# Major -- bump on incompatible API break.
|
|
# Minor -- bump on backward-compatible feature add.
|
|
# Revision -- bump on bug fix only (no API change).
|
|
# Build -- internal counter, often 0 for hand-released versions.
|
|
#
|
|
# Bumping a higher part resets all lower parts to 0 (e.g. bumping minor
|
|
# resets revision and build).
|
|
#
|
|
# The Version field is read/written as a property on the "Project
|
|
# Information" node (first child of the primary project). IronPython
|
|
# coerces strings like "1.2.3.4" to System.Version automatically; we
|
|
# stringify on the read side because str(System.Version) gives
|
|
# the dotted form back. None / empty are treated as "0.0.0.0".
|
|
|
|
LEVEL = "{LEVEL}" # major | minor | revision | build
|
|
|
|
VALID_LEVELS = ('major', 'minor', 'revision', 'build')
|
|
|
|
# Standard runtime-readable version anchor. Lives as a constant in a GVL
|
|
# under Application so any IEC code can read it as
|
|
# _MCP_PROJECT_VERSION.sVersion, and a future read_running_version_online
|
|
# tool can pull it via online connect + read_variable. Kept as
|
|
# qualified_only so it can't accidentally shadow a same-named local.
|
|
VERSION_GVL_NAME = '_MCP_PROJECT_VERSION'
|
|
# Plain VAR_GLOBAL (NOT CONSTANT). CODESYS inlines CONSTANT scalars at
|
|
# compile time and strips them from the online symbol table, which would
|
|
# break read_running_version_online with 'Invalid expression'. Plain
|
|
# VAR_GLOBAL keeps the symbol live without needing extra attributes -- a
|
|
# verified-working test case in MCPTest2 (GVL_Test.bRun) reads fine over
|
|
# the online protocol despite zero IEC references, so a Symbol
|
|
# Configuration / 'symbol' attribute is NOT required for the runtime to
|
|
# expose unreferenced globals. The string is still effectively read-only
|
|
# at runtime since only bump_project_version updates it via
|
|
# textual_declaration.replace.
|
|
# qualified_only attribute kept so callers must use the full qualified
|
|
# name in IEC code (avoids accidental shadowing of a same-named local).
|
|
VERSION_GVL_DECLARATION_TEMPLATE = (
|
|
"{attribute 'qualified_only'}\n"
|
|
"VAR_GLOBAL\n"
|
|
" sVersion : STRING := '%s';\n"
|
|
"END_VAR\n"
|
|
)
|
|
|
|
|
|
def parse_version(v):
|
|
"""Parse a version-like value into a 4-tuple of ints, defaulting missing
|
|
parts to 0. Accepts None, '', '1.2', '1.2.3', '1.2.3.4', or a
|
|
System.Version. Raises ValueError on anything that can't be parsed."""
|
|
if v is None:
|
|
return (0, 0, 0, 0)
|
|
s = str(v).strip()
|
|
if not s or s == 'None':
|
|
return (0, 0, 0, 0)
|
|
parts = s.split('.')
|
|
if len(parts) > 4:
|
|
raise ValueError("version '%s' has more than 4 parts" % s)
|
|
nums = []
|
|
for p in parts:
|
|
try:
|
|
nums.append(int(p))
|
|
except ValueError:
|
|
raise ValueError("version '%s' has non-integer part '%s'" % (s, p))
|
|
while len(nums) < 4:
|
|
nums.append(0)
|
|
return tuple(nums)
|
|
|
|
|
|
def read_version_from_gvl(primary_project):
|
|
"""When Project Information is missing, read the current version back
|
|
from the runtime anchor GVL (_MCP_PROJECT_VERSION.sVersion) so subsequent
|
|
bumps can resume from the actual current state instead of re-seeding to
|
|
1.0.0.0 on every call. Returns the version string or None if the GVL
|
|
doesn't exist yet (true first-run)."""
|
|
try:
|
|
app = getattr(primary_project, 'active_application', None)
|
|
except Exception:
|
|
app = None
|
|
if app is None:
|
|
try:
|
|
apps = primary_project.find('Application', True)
|
|
if apps:
|
|
app = apps[0]
|
|
except Exception:
|
|
pass
|
|
if app is None:
|
|
return None
|
|
try:
|
|
for child in app.get_children(False):
|
|
try:
|
|
if child.get_name() != VERSION_GVL_NAME:
|
|
continue
|
|
decl = child.textual_declaration.text or ''
|
|
m = re.search(r"sVersion\s*:\s*STRING\s*:=\s*'(\d+\.\d+\.\d+\.\d+)'", decl)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def maintain_version_gvl(primary_project, version_str):
|
|
"""Find or create the _MCP_PROJECT_VERSION GVL under the active
|
|
Application, and set its declaration so the running PLC carries the
|
|
project version as a constant string. Soft-fails on any error -- the
|
|
primary outcome of the bump (Project Information.Version) has already
|
|
succeeded by the time this is called, so a GVL creation failure is
|
|
logged as a WARNING but does not fail the whole tool."""
|
|
try:
|
|
app = getattr(primary_project, 'active_application', None)
|
|
except Exception:
|
|
app = None
|
|
if app is None:
|
|
try:
|
|
apps = primary_project.find('Application', True)
|
|
if apps:
|
|
app = apps[0]
|
|
except Exception:
|
|
pass
|
|
if app is None:
|
|
print("WARNING: no active Application found -- cannot maintain %s GVL" % VERSION_GVL_NAME)
|
|
return False
|
|
|
|
decl = VERSION_GVL_DECLARATION_TEMPLATE % version_str
|
|
|
|
# Try to find existing GVL with this name
|
|
existing = None
|
|
try:
|
|
for child in app.get_children(False):
|
|
try:
|
|
if child.get_name() == VERSION_GVL_NAME:
|
|
existing = child
|
|
break
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
print("WARNING: walking Application children failed: %s" % e)
|
|
|
|
if existing is not None:
|
|
try:
|
|
existing.textual_declaration.replace(decl)
|
|
print("DEBUG: updated %s -> sVersion := '%s'" % (VERSION_GVL_NAME, version_str))
|
|
return True
|
|
except Exception as e:
|
|
print("WARNING: failed to update existing %s declaration: %s" % (VERSION_GVL_NAME, e))
|
|
return False
|
|
|
|
# Create it
|
|
if not hasattr(app, 'create_gvl'):
|
|
print("WARNING: Application object doesn't expose create_gvl -- cannot create %s" % VERSION_GVL_NAME)
|
|
return False
|
|
try:
|
|
new_gvl = app.create_gvl(name=VERSION_GVL_NAME)
|
|
if new_gvl is None:
|
|
print("WARNING: create_gvl returned None for %s" % VERSION_GVL_NAME)
|
|
return False
|
|
new_gvl.textual_declaration.replace(decl)
|
|
print("DEBUG: created %s with sVersion := '%s'" % (VERSION_GVL_NAME, version_str))
|
|
return True
|
|
except Exception as e:
|
|
print("WARNING: failed to create %s: %s" % (VERSION_GVL_NAME, e))
|
|
return False
|
|
|
|
|
|
def bump(parts, level):
|
|
major, minor, revision, build = parts
|
|
if level == 'major':
|
|
return (major + 1, 0, 0, 0)
|
|
if level == 'minor':
|
|
return (major, minor + 1, 0, 0)
|
|
if level == 'revision':
|
|
return (major, minor, revision + 1, 0)
|
|
if level == 'build':
|
|
return (major, minor, revision, build + 1)
|
|
raise ValueError("unknown bump level '%s' (must be one of %s)" % (level, ', '.join(VALID_LEVELS)))
|
|
|
|
|
|
try:
|
|
if LEVEL not in VALID_LEVELS:
|
|
raise ValueError("level must be one of %s, got '%s'" % (', '.join(VALID_LEVELS), LEVEL))
|
|
|
|
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
|
|
|
# Find the Project Information node via the official is_project_info
|
|
# marker rather than name-matching. Walk the project tree -- the node is
|
|
# usually at the root, but locating it via the marker is robust against
|
|
# localised IDE display names ('Projektinformation' in DE, etc.) and
|
|
# against projects where the node lives at a different depth.
|
|
pi = None
|
|
def _find_pi(node, depth=0, max_depth=4):
|
|
if depth > max_depth:
|
|
return None
|
|
try:
|
|
if getattr(node, 'is_project_info', False):
|
|
return node
|
|
except Exception:
|
|
pass
|
|
try:
|
|
for c in node.get_children(False):
|
|
hit = _find_pi(c, depth + 1, max_depth)
|
|
if hit is not None:
|
|
return hit
|
|
except Exception:
|
|
pass
|
|
return None
|
|
pi = _find_pi(primary_project)
|
|
|
|
# Some projects (notably ones created from the Standard template via the
|
|
# scripting create_project flow) have no Project Information node at all
|
|
# -- the IDE adds it lazily the first time the user opens
|
|
# Project menu -> Project Information. We don't have a documented way to
|
|
# create one via scripting, so handle it gracefully: skip the metadata
|
|
# write but still maintain the runtime-readable GVL, which is the
|
|
# source-of-truth at runtime anyway. The user can add Project Information
|
|
# manually via the IDE later if they want the metadata side too.
|
|
pi_missing = pi is None
|
|
if pi_missing:
|
|
print("WARNING: Project Information node not found in project tree -- "
|
|
"skipping metadata write. The runtime anchor (GVL) will still be "
|
|
"maintained. To add the Project Information node, open the "
|
|
"Project menu -> Project Information in the IDE; subsequent bumps "
|
|
"will then update both metadata and GVL.")
|
|
# Fall back to reading the existing GVL so we resume from the actual
|
|
# current version instead of re-seeding to 1.0.0.0 every call.
|
|
before_raw = read_version_from_gvl(primary_project)
|
|
if before_raw:
|
|
print("DEBUG: Project Information missing, resuming from GVL: %s" % before_raw)
|
|
else:
|
|
# Cross-check pi.version against the runtime-anchor GVL and take the
|
|
# max of the two as the resume point. Drift between the two sides
|
|
# happens when a release is finished by an external (non-MCP) script
|
|
# that updates one but not the other -- the GVL gets refreshed via
|
|
# inject-once writes, and pi.version gets refreshed via direct .project
|
|
# binary edits, but a script that touches only one leaves the other
|
|
# stale. Without the cross-check, the next bump would silently regress
|
|
# the version (observed: pi.version stuck at 1.0.0.0 while GVL was at
|
|
# 1.2.1.0 -> minor bump gave 1.1.0.0, colliding with an existing tag).
|
|
# Taking the max is always safe: the GVL only ever moves forward (set
|
|
# by maintain_version_gvl on every bump), and pi.version only ever
|
|
# moves forward (set by pi.version assignment). The higher of the
|
|
# two is the true latest version regardless of which side drifted.
|
|
pi_raw = pi.version
|
|
gvl_raw = read_version_from_gvl(primary_project)
|
|
pi_parts = parse_version(pi_raw) if pi_raw is not None else (0, 0, 0, 0)
|
|
gvl_parts = parse_version(gvl_raw) if gvl_raw else (0, 0, 0, 0)
|
|
if pi_parts >= gvl_parts:
|
|
before_raw = pi_raw
|
|
else:
|
|
print("WARNING: Project Information.Version (%s) is BEHIND the runtime anchor "
|
|
"GVL (%s) -- this happens when a previous release was finished by an "
|
|
"external script that updated the GVL but not the .project metadata. "
|
|
"Using the GVL value as the resume point so the bump doesn't regress." % (
|
|
pi_raw, gvl_raw))
|
|
before_raw = gvl_raw
|
|
# Heal pi.version forward to the GVL value before the bump so this
|
|
# warning doesn't recur on the next call.
|
|
try:
|
|
pi.version = gvl_raw
|
|
print("DEBUG: healed Project Information.Version: %s -> %s (matching GVL)" % (
|
|
pi_raw, gvl_raw))
|
|
except Exception as heal_e:
|
|
print("WARNING: could not heal Project Information.Version: %s" % heal_e)
|
|
before_str = str(before_raw) if before_raw is not None else None
|
|
|
|
# First-run convention: if no version is set yet, seed at 1.0.0.0 instead
|
|
# of treating "no version" as 0.0.0.0 + bump (which would give 0.0.0.1
|
|
# for level=build, awkward for a first canonical version). Most projects
|
|
# start tracking at 1.0.0.0 when they first turn on versioning, and the
|
|
# level argument is moot for the seed step.
|
|
seed_check = parse_version(before_raw)
|
|
if seed_check == (0, 0, 0, 0) and (before_raw is None or str(before_raw).strip() in ('', '0.0.0.0', 'None')):
|
|
after_parts = (1, 0, 0, 0)
|
|
after_str = '1.0.0.0'
|
|
print("DEBUG: bump_project_version: no prior version -- seeding to 1.0.0.0 (level=%s ignored on first run)" % LEVEL)
|
|
else:
|
|
before_parts = seed_check
|
|
after_parts = bump(before_parts, LEVEL)
|
|
after_str = '%d.%d.%d.%d' % after_parts
|
|
print("DEBUG: bump_project_version: level=%s before=%s -> after=%s" % (
|
|
LEVEL, before_str, after_str))
|
|
|
|
if not pi_missing:
|
|
pi.version = after_str
|
|
|
|
# Maintain the runtime-readable version anchor (_MCP_PROJECT_VERSION GVL)
|
|
# so the running PLC carries the same string. Soft-fails so the primary
|
|
# bump still reports success even if GVL creation hits an edge case.
|
|
gvl_ok = maintain_version_gvl(primary_project, after_str)
|
|
|
|
try:
|
|
primary_project.save()
|
|
print("DEBUG: project.save() succeeded after version bump.")
|
|
except Exception as save_e:
|
|
print("WARNING: project.save() raised %s -- bump applied in memory but may not persist across IDE close." % save_e)
|
|
|
|
if pi_missing:
|
|
print("Project Information.Version: (skipped -- node missing) -> %s" % after_str)
|
|
else:
|
|
print("Project Information.Version: %s -> %s" % (before_str, after_str))
|
|
if gvl_ok:
|
|
print("Runtime anchor: %s.sVersion := '%s'" % (VERSION_GVL_NAME, after_str))
|
|
else:
|
|
print("Runtime anchor: %s NOT updated (see WARNING above)" % VERSION_GVL_NAME)
|
|
print("SCRIPT_SUCCESS: bump_project_version complete.")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
detailed = traceback.format_exc()
|
|
msg = "Error in bump_project_version for project '%s': %s\n%s" % (
|
|
PROJECT_FILE_PATH, e, detailed)
|
|
print(msg)
|
|
print("SCRIPT_ERROR: %s" % msg)
|
|
sys.exit(1)
|