Skip to content

Instantly share code, notes, and snippets.

@cprima
Last active February 4, 2026 13:33
Show Gist options
  • Select an option

  • Save cprima/87e68d4732170820e71eafb44255efc0 to your computer and use it in GitHub Desktop.

Select an option

Save cprima/87e68d4732170820e71eafb44255efc0 to your computer and use it in GitHub Desktop.
Python script to mass-edit UiPath Studio .xaml files after a UIAutomation dependency downgrade.

fix-uia-downgrade

Fix UiPath files after a UIAutomation dependency downgrade.

Supports both workflow XAML files and Object Repository .content files. Removes attributes and caps Version values that older activity packs don't recognise. Edits are regex-based (no XML parsing) so formatting is preserved exactly.

Prerequisites

  • Python 3.11+
  • uv

Catalog

Knowledge is stored in three tables in uia_catalog.py:

Table Purpose
ELEMENT_ATTRS_INTRODUCED Which attrs were introduced at which element Version level
DEPENDENCY_CEILINGS Max element Version per dependency version
DEPENDENCY_UNKNOWN_ATTRS Attrs absent from the dependency entirely

The script dynamically computes the removal delta from these tables.

Usage

Subcommands

Workflow XAML files (**/*.xaml):

# Dry-run (default)
uv run fix-uia workflows --root /path/to/project

# Apply changes (creates .xaml.bak per file)
uv run fix-uia workflows --root /path/to/project --apply

Object Repository files (.objects/**/.content):

# Dry-run
uv run fix-uia objects --root /path/to/project

# Apply changes (creates .content.bak per file)
uv run fix-uia objects --root /path/to/project --apply

Both workflows and objects:

uv run fix-uia all --root /path/to/project --apply

Common Options

# Skip backups
uv run fix-uia objects --root /path/to/project --apply --no-backup

# Select target dependency version
uv run fix-uia objects --root /path/to/project --target 24.10

# Workflows: exclude directories
uv run fix-uia workflows --root /path/to/project --exclude-dir RnD --exclude-dir Scratch

What Gets Fixed

For --target 24.10:

  • TargetAnchorable: Version="V6"Version="V4"
  • TargetApp: Version="V2"Version="V1"
  • Attributes: Removes ContentHash and Reference from TargetApp V2+

Adding new rules

Edit uia_catalog.py:

  • New version-gated attr: add to ELEMENT_ATTRS_INTRODUCED
  • New dependency version: add ceilings to DEPENDENCY_CEILINGS and unknown attrs to DEPENDENCY_UNKNOWN_ATTRS
__pycache__/
*.egg-info/
.venv/
uv.lock
layout title date tags
post
Fixing UiPath XAML files after a UIAutomation dependency downgrade
2026-02-03
uipath
xaml
python
automation

When you downgrade UiPath.UIAutomation.Activities in a UiPath Studio project, workflows that were saved with the newer version won't open. Studio rejects element Version values it doesn't recognise and chokes on attributes that didn't exist in the older package.

This post explains what breaks, why, and how to fix it automatically with a small Python script.

What breaks

UiPath Studio serialises each UI automation activity into XAML with a Version attribute. The value tracks which schema the element was saved with:

<uix:NTypeInto ClipboardMode="Never" Version="V5" ... />

When you downgrade the activity pack, the version enum shrinks. If NActivityVersion in 24.10 only defines V1..V4, Studio cannot parse V5 and throws:

Failed to create a 'Version' from the text 'V5'.

Some attributes are introduced alongside a version bump. Others are added to the dependency globally and don't exist at all in older packages. Both must be removed for the file to open.

Three kinds of breakage

  1. Version too high -- the element's Version="V5" exceeds what the target dependency supports (e.g. max V4). The version must be capped.

  2. Version-gated attributes -- attributes introduced at the higher version. ClipboardMode on NTypeInto was added at V5; when capping to V4 it must be stripped, otherwise Studio still rejects the file.

  3. Globally unknown attributes -- attributes that the older dependency doesn't know at any version. HealingAgentBehavior was added globally in 25.10 and must be removed unconditionally.

The catalog approach

Rather than hardcoding removal rules, the script stores knowledge in three flat tables:

# What attrs were introduced at each element Version level.
ELEMENT_ATTRS_INTRODUCED = {
    "NGetText":  {"V5": ["ClipboardMode"]},
    "NTypeInto": {"V5": ["ClipboardMode"]},
    "TargetApp": {"V2": ["ContentHash", "Reference"]},
}

# Max element Version supported per dependency version.
DEPENDENCY_CEILINGS = {
    "25.10": {"NGetText": "V5", "NTypeInto": "V5", ...},
    "24.10": {"NGetText": "V4", "NTypeInto": "V4", ...},
}

# Attrs absent from the dependency entirely.
DEPENDENCY_UNKNOWN_ATTRS = {
    "24.10": ["HealingAgentBehavior"],
}

Given a target (e.g. 24.10), the script dynamically computes the delta: it collects all attributes introduced above the target's ceiling for each element, removes them, and caps the version.

Adding a new discovery is a single catalog entry. If a future V6 introduces FooBar on NGetText, adding "V6": ["FooBar"] to the catalog automatically handles it for every target with a ceiling below V6.

How the fix works

The script processes XAML files line by line using regex (no XML parsing), which preserves the original formatting, attribute order, and encoding exactly.

For each line:

  1. Global removal -- strip attributes from DEPENDENCY_UNKNOWN_ATTRS regardless of element type or version.

  2. Version-gated fix -- if the line opens a known element and its Version exceeds the target ceiling:

    • Remove all attributes introduced above the ceiling (computed from ELEMENT_ATTRS_INTRODUCED).
    • Cap Version to the ceiling value.

Lines that don't match any rule pass through unchanged.

Discovering the ceilings

Two approaches for determining what each dependency version supports:

Decompile the NuGet package. The activity packs contain .NET DLLs with version enums (NActivityVersion, NTargetVersion, NTargetAppVersion). The enum members directly tell you the max version. Diffing two package versions reveals exactly what changed.

Binary search with a reference XAML. Create a file with every element type at the highest known version. Switch dependency versions, open in Studio, record what errors. This is slower but catches runtime behaviour that decompilation might miss.

In practice, the reference-file approach is how the current catalog was built: a Scratch_downgrade.xaml was created with the target dependency, and elements were dragged in to determine their valid versions.

Usage

The script is packaged as a standalone uv project:

uv run fix-uia-xaml --root /path/to/uipath/project

Dry-run by default. Shows what would change:

target: 24.10
  Process\Cms\Login.xaml: -HealingAgentBehavior x6, ...
  Process\Cms\Logout.xaml: -HealingAgentBehavior x4, ...

DRY-RUN: 12 file(s), 594 edit(s)

Apply with --apply:

uv run fix-uia-xaml --root /path/to/project --apply

The script is idempotent -- running it again after applying produces zero edits.

Caveat: .venv and UiPath Studio

If you have Python virtual environments (.venv) inside the UiPath project tree, Studio will try to enumerate them and crash on the lib64 -> lib symlink that Python creates on Linux-style venvs. This is unrelated to the XAML fix but can mask whether the fix worked. Delete or move .venv directories out of the project root before opening in Studio.

#!/usr/bin/env python3
"""Fix XAML files after a UiPath UIAutomation dependency downgrade.
Removes attributes and caps Version values that the older activity packs
don't recognise. All edits are regex-based (no XML parsing) so the
original formatting, attribute order and encoding are preserved exactly.
The script dynamically computes the delta from the catalog tables in
``uia_catalog.py``: given a target dependency version it collects all
attrs introduced above the ceiling for each element, removes them, and
caps the Version.
"""
from __future__ import annotations
import fnmatch
import os
import re
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
import typer
from uia_catalog import (
DEFAULT_TARGET,
DEPENDENCY_CEILINGS,
DEPENDENCY_UNKNOWN_ATTRS,
ELEMENT_ATTRS_INTRODUCED,
)
# ── helpers ──────────────────────────────────────────────────────────────
DEFAULT_EXCLUDE_DIRS = {
".git", ".local", ".objects", ".screenshots",
"bin", "obj", ".idea", ".vs", "__pycache__",
}
_ATTR_RE_CACHE: dict[str, re.Pattern[str]] = {}
def _attr_re(name: str) -> re.Pattern[str]:
"""Compile (and cache) a regex that matches ` name="..."`."""
if name not in _ATTR_RE_CACHE:
_ATTR_RE_CACHE[name] = re.compile(rf'\s+{re.escape(name)}="[^"]*"')
return _ATTR_RE_CACHE[name]
def _version_int(v: str) -> int:
"""'V4' -> 4. Returns -1 for unparseable values."""
if v.startswith("V") and v[1:].isdigit():
return int(v[1:])
return -1
def _attrs_to_remove(element: str, ceiling: str) -> list[str]:
"""Collect all attrs introduced above *ceiling* for *element*."""
ceiling_n = _version_int(ceiling)
result: list[str] = []
for ver, attrs in ELEMENT_ATTRS_INTRODUCED.get(element, {}).items():
if _version_int(ver) > ceiling_n:
result.extend(attrs)
return result
# Matches an opening tag: <prefix:LocalName or <LocalName
_OPEN_TAG_RE = re.compile(r"<(?:\w+:)?(\w+)\s")
# Matches Version="Vnn" and captures the parts
_VERSION_VALUE_RE = re.compile(r'(Version=")V(\d+)(")')
# ── file discovery ───────────────────────────────────────────────────────
def iter_files(
root: Path,
globs: list[str],
exclude_dirs: set[str],
) -> list[Path]:
result: list[Path] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in exclude_dirs)
pdir = Path(dirpath)
for name in sorted(filenames):
full = pdir / name
rel_posix = str(full.relative_to(root)).replace("\\", "/")
if any(fnmatch.fnmatch(rel_posix, g) for g in globs):
result.append(full)
return result
# ── core logic ───────────────────────────────────────────────────────────
@dataclass
class FileChanges:
"""Accumulates change counts for a single file."""
removed_global: dict[str, int] = field(default_factory=dict)
removed_element: dict[str, dict[str, int]] = field(default_factory=dict)
version_caps: dict[str, int] = field(default_factory=dict)
@property
def total(self) -> int:
return (
sum(self.removed_global.values())
+ sum(n for d in self.removed_element.values() for n in d.values())
+ sum(self.version_caps.values())
)
def _line_has_version_above(line: str, max_ver: str) -> bool:
"""Return True if the line contains Version="Vn" with n > max."""
max_n = _version_int(max_ver)
for m in _VERSION_VALUE_RE.finditer(line):
if int(m.group(2)) > max_n:
return True
return False
def _cap_version_in_line(line: str, max_ver: str) -> tuple[str, int]:
"""Cap Version="Vn" on *line* to *max_ver*.
Returns (new_line, count_of_replacements)."""
max_n = _version_int(max_ver)
count = 0
def _replacer(m: re.Match[str]) -> str:
nonlocal count
if int(m.group(2)) > max_n:
count += 1
return f"{m.group(1)}{max_ver}{m.group(3)}"
return m.group(0)
new_line = _VERSION_VALUE_RE.sub(_replacer, line)
return new_line, count
def process_content(text: str, target_key: str) -> tuple[str, FileChanges]:
"""Apply all rules for *target_key* to *text* and return (new_text, changes)."""
ceilings = DEPENDENCY_CEILINGS[target_key]
global_attrs = DEPENDENCY_UNKNOWN_ATTRS.get(target_key, [])
changes = FileChanges()
lines = text.split("\n")
new_lines: list[str] = []
for line in lines:
# 1) Global attribute removal (unconditional)
for attr in global_attrs:
pat = _attr_re(attr)
line, n = pat.subn("", line)
if n:
changes.removed_global[attr] = (
changes.removed_global.get(attr, 0) + n
)
# 2) Version-gated element rules (dynamic delta)
m = _OPEN_TAG_RE.search(line)
if m:
local_name = m.group(1)
ceiling = ceilings.get(local_name)
if ceiling and _line_has_version_above(line, ceiling):
# version needs capping -> also strip attrs above ceiling
for attr in _attrs_to_remove(local_name, ceiling):
pat = _attr_re(attr)
line, n = pat.subn("", line)
if n:
elem_d = changes.removed_element.setdefault(
local_name, {},
)
elem_d[attr] = elem_d.get(attr, 0) + n
line, n = _cap_version_in_line(line, ceiling)
if n:
changes.version_caps[local_name] = (
changes.version_caps.get(local_name, 0) + n
)
new_lines.append(line)
return "\n".join(new_lines), changes
def process_file(
path: Path,
target_key: str,
*,
apply: bool,
backup: bool,
) -> FileChanges:
raw = path.read_bytes()
# detect encoding (UTF-8-BOM, UTF-16, or plain UTF-8)
if raw[:3] == b"\xef\xbb\xbf":
encoding = "utf-8-sig"
elif raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
encoding = "utf-16"
else:
encoding = "utf-8"
text = raw.decode(encoding)
new_text, changes = process_content(text, target_key)
if changes.total == 0:
return changes
if apply:
if backup:
bak = path.with_suffix(path.suffix + ".bak")
if not bak.exists():
path.rename(bak)
path.write_bytes(new_text.encode(encoding))
return changes
# ── reporting ────────────────────────────────────────────────────────────
def format_file_changes(changes: FileChanges, target_key: str) -> str:
ceilings = DEPENDENCY_CEILINGS[target_key]
parts: list[str] = []
for attr, n in sorted(changes.removed_global.items()):
parts.append(f"-{attr} x{n}")
for elem, attrs in sorted(changes.removed_element.items()):
for attr, n in sorted(attrs.items()):
parts.append(f"-{elem}.{attr} x{n}")
for elem, n in sorted(changes.version_caps.items()):
parts.append(f"{elem}.Version->{ceilings[elem]} x{n}")
return ", ".join(parts)
# ── CLI ──────────────────────────────────────────────────────────────────
app = typer.Typer(
name="fix-uia",
help="Fix UiPath files after UIAutomation dependency downgrade",
no_args_is_help=True,
)
class TargetVersion(str, Enum):
"""Target dependency version."""
v24_10 = "24.10"
v25_10 = "25.10"
def _process_files(
root: Path,
globs: list[str],
exclude_dirs: set[str],
target_key: str,
apply: bool,
backup: bool,
) -> None:
"""Core processing logic."""
typer.echo(f"target: {target_key}")
files = iter_files(root, globs, exclude_dirs)
files_with_hits = 0
total_changes = 0
for f in files:
changes = process_file(f, target_key, apply=apply, backup=backup)
if changes.total == 0:
continue
files_with_hits += 1
total_changes += changes.total
rel = f.relative_to(root)
typer.echo(f" {rel}: {format_file_changes(changes, target_key)}")
mode = "APPLIED" if apply else "DRY-RUN"
typer.echo(f"\n{mode}: {files_with_hits} file(s), {total_changes} edit(s)")
if not apply and total_changes:
typer.echo(" (re-run with --apply to write changes)")
@app.command()
def workflows(
root: Path = typer.Option(".", "--root", help="Project root directory"),
target: TargetVersion = typer.Option(
TargetVersion.v24_10, "--target", help="Target dependency version"
),
apply: bool = typer.Option(False, "--apply", help="Write changes (default is dry-run)"),
backup: bool = typer.Option(True, "--backup/--no-backup", help="Create .bak files"),
exclude_dir: list[str] = typer.Option(
None, "--exclude-dir", help="Extra directory names to skip"
),
) -> None:
"""Fix workflow XAML files (*.xaml)."""
_process_files(
root=root.resolve(),
globs=["**/*.xaml", "*.xaml"],
exclude_dirs=set(DEFAULT_EXCLUDE_DIRS) | set(exclude_dir or []),
target_key=target.value,
apply=apply,
backup=backup,
)
@app.command()
def objects(
root: Path = typer.Option(".", "--root", help="Project root directory"),
target: TargetVersion = typer.Option(
TargetVersion.v24_10, "--target", help="Target dependency version"
),
apply: bool = typer.Option(False, "--apply", help="Write changes (default is dry-run)"),
backup: bool = typer.Option(True, "--backup/--no-backup", help="Create .bak files"),
) -> None:
"""Fix object repository .content files."""
exclude_dirs = set(DEFAULT_EXCLUDE_DIRS) - {".objects"} # Remove .objects exclusion
_process_files(
root=root.resolve(),
globs=[".objects/**/.content"],
exclude_dirs=exclude_dirs,
target_key=target.value,
apply=apply,
backup=backup,
)
@app.command()
def all(
root: Path = typer.Option(".", "--root", help="Project root directory"),
target: TargetVersion = typer.Option(
TargetVersion.v24_10, "--target", help="Target dependency version"
),
apply: bool = typer.Option(False, "--apply", help="Write changes (default is dry-run)"),
backup: bool = typer.Option(True, "--backup/--no-backup", help="Create .bak files"),
) -> None:
"""Fix both workflow XAML and object repository files."""
exclude_dirs = set(DEFAULT_EXCLUDE_DIRS) - {".objects"}
_process_files(
root=root.resolve(),
globs=["**/*.xaml", "*.xaml", ".objects/**/.content"],
exclude_dirs=exclude_dirs,
target_key=target.value,
apply=apply,
backup=backup,
)
if __name__ == "__main__":
app()
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "fix-uia-downgrade"
version = "0.1.0"
description = "Fix UiPath files after UIAutomation dependency downgrade"
requires-python = ">=3.11"
dependencies = ["typer"]
[project.scripts]
fix-uia = "fix_uia_xaml:app"
[tool.setuptools]
py-modules = ["fix_uia_xaml", "uia_catalog"]
"""Element version catalog for UiPath.UIAutomation.Activities.
Three tables that describe what each dependency version supports.
Add new entries as they are discovered from Studio errors.
"""
# Which attrs were introduced at each element Version level.
# Key: element local name. Value: {version: [attr_names]}.
# Built incrementally — when a new Studio error appears, add the entry.
ELEMENT_ATTRS_INTRODUCED: dict[str, dict[str, list[str]]] = {
"NGetText": {"V5": ["ClipboardMode"]},
"NTypeInto": {"V5": ["ClipboardMode"]},
"TargetApp": {"V2": ["ContentHash", "Reference"]},
"TargetAnchorable": {}, # no known version-gated attrs yet
}
# Max element Version supported per dependency version.
# Key: dependency version tag (used with --target on the CLI).
# Value: {element local name: max version string}.
#
# 25.10 — baseline recorded from files created with [25.10.12],
# representative file: Process/AddressDirectory/AcquirePointSession.xaml
# 24.10 — determined from working Crm/ files and RnD/Scratch_downgrade.xaml
DEPENDENCY_CEILINGS: dict[str, dict[str, str]] = {
"25.10": {
"NCheckState": "V5",
"NClick": "V5",
"NGetAttributeGeneric": "V5",
"NGetText": "V5",
"NHighlight": "V5",
"NKeyboardShortcuts": "V5",
"NMouseScroll": "V5",
"NSelectItem": "V5",
"NTypeInto": "V5",
"TargetAnchorable": "V6",
"TargetApp": "V2",
},
"24.10": {
"NCheckState": "V3",
"NClick": "V3",
"NGetAttributeGeneric": "V4",
"NGetText": "V4",
"NHighlight": "V4",
"NKeyboardShortcuts": "V3",
"NMouseScroll": "V4",
"NSelectItem": "V4",
"NTakeScreenshot": "V3",
"NTypeInto": "V4",
"TargetAnchorable": "V4",
"TargetApp": "V1",
},
}
# Attrs absent from the dependency entirely (not version-gated).
# Key: dependency version tag. Value: [attr_names].
DEPENDENCY_UNKNOWN_ATTRS: dict[str, list[str]] = {
"24.10": ["HealingAgentBehavior"],
}
DEFAULT_TARGET = "24.10"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment