PowerShell не различает регистр нигде, куда попадает пользовательский ввод: свойства объекта из ConvertFrom-Json, ключи Hashtable, -eq/-contains, имена параметров, ValidateSet. Python различает везде, поэтому один и тот же DSL давал разный результат на разных портах — и чаще всего молча: "CodeLength" вместо "codeLength" в py просто не находился, навык печатал [OK], а свойство в выход не попадало. Пилот на meta-compile. В py-порт добавлены общие обёртки: CIDict (поиск без учёта регистра, ключи хранятся как есть — часть из них имена объектов и попадает в XML), ci_json (рекурсивно на разобранный DSL), ci_parse_args (имена параметров и значения choices). Ими же обёрнуты словари синонимов видов, алиасов enum и типов. Попутно исправлен дефект самого канона: PS принимал "type":"catalog", но дальше использовал значение как есть — в имени тега и в регистрации в Configuration.xml, то есть отдавал <catalog>, которую платформа не примет. Теперь вид приводится к канону списка в обоих портах. check-inline-drift: extractPy научен доставать class (иначе тело CIDict невидимо), заведены три семьи с ps1: null — в PS1 этих обёрток быть не должно. Проверка: кейс lenient-key-case зелёный на обоих портах; полный регресс 673/673 (PS) и 670+3 skipped (PY); гарды 4/4; sweep по 400 справочникам трёх конфигураций (декомпиляция → компиляция обоими портами) — 0 расхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 KiB
Python Porting Guide
Руководство по Python-портам навыков 1С (PS1 → Python).
Зачем Python рядом с PS1
PowerShell 5.1 доступен только на Windows. Python-порты обеспечивают кроссплатформенность (Linux, Mac). Модель opt-in: PS1 — по умолчанию, Python — переключается скриптами.
PS1 — мастер-версия
Приоритет при разработке, доработке, отладке и тестировании — у PS1-скриптов. Python-порты являются производными копиями. Порядок работы:
- Вносите изменения в
.ps1 - Тестируйте и отлаживайте
.ps1 - Переносите готовые изменения в
.py
Не дорабатывайте .py без аналогичного изменения в .ps1 — они должны оставаться функционально идентичными.
Переключение рантайма
# Переключить все .md в навыках на Python
python scripts/switch-to-python.py
# Вернуть на PowerShell
python scripts/switch-to-powershell.py
Скрипты обрабатывают все .md файлы в .claude/skills/*/ (SKILL.md, json-dsl.md и др.). Идемпотентны — повторный запуск безопасен. Python-only навыки (img-grid) пропускаются при переключении на PowerShell.
Принцип самодостаточности
Каждый .py — полностью автономен, как и его .ps1-аналог. Нет общих модулей. Это соответствует рекомендациям Anthropic и зеркалит существующую архитектуру PS1.
Общие утилиты (5-15 строк) дублируются в каждом скрипте:
def esc_xml(s):
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
def emit_mltext(lines, indent, tag, text):
if not text:
lines.append(f"{indent}<{tag}/>")
return
lines.append(f"{indent}<{tag}>")
lines.append(f"{indent}\t<v8:item>")
lines.append(f"{indent}\t\t<v8:lang>ru</v8:lang>")
lines.append(f"{indent}\t\t<v8:content>{esc_xml(text)}</v8:content>")
lines.append(f"{indent}\t</v8:item>")
lines.append(f"{indent}</{tag}>")
def new_uuid():
import uuid
return str(uuid.uuid4())
def read_utf8(path):
with open(path, 'r', encoding='utf-8-sig') as f:
return f.read()
def write_utf8_bom(path, content):
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
Большие словари данных (синонимы типов, карты объектов) тоже inline — как $script:typeSynonyms в PS1.
Конвенция параметров
Формат -ParamName сохранён для минимальных различий в SKILL.md:
parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument('-JsonPath', dest='JsonPath', required=True)
parser.add_argument('-NoValidate', dest='NoValidate', action='store_true')
Switch-параметры (-NoValidate) → action='store_true'.
Таблица маппинга PS → Python
| PS1 | Python |
|---|---|
$script:xml = New-Object StringBuilder |
lines = [] |
$xml.AppendLine($text) |
lines.append(text) |
$xml.ToString() |
'\n'.join(lines) |
[System.Xml.XmlDocument] + PreserveWhitespace |
lxml.etree.XMLParser(remove_blank_text=False) |
$xmlDoc.SelectSingleNode(xpath, $ns) |
root.find(xpath, namespaces=NSMAP) |
$xmlDoc.SelectNodes(xpath, $ns) |
root.findall(xpath, namespaces=NSMAP) |
XmlWriter + MemoryStream + BOM fix |
etree.tostring(root, xml_declaration=True, encoding='UTF-8') + declaration fix |
[System.Guid]::NewGuid().ToString() |
str(uuid.uuid4()) |
$json | ConvertFrom-Json |
json.loads(text) |
ConvertTo-Json -Depth 10 |
json.dumps(obj, ensure_ascii=False, indent=2) |
New-Object System.Text.UTF8Encoding($true) |
encoding='utf-8-sig' |
Start-Process -Wait -PassThru |
subprocess.run([...], capture_output=True) |
Start-Process (без -Wait) |
subprocess.Popen([...]) |
[switch]$NoValidate |
parser.add_argument('-NoValidate', action='store_true') |
[ValidateSet("a","b")] |
choices=["a","b"] |
Get-ChildItem "path\*\..." |
glob.glob(...) |
Get-Process httpd |
psutil.process_iter(['pid','name','exe']) |
Test-Path $path |
os.path.exists(path) |
Resolve-Path |
os.path.abspath() |
Join-Path $a $b |
os.path.join(a, b) |
New-Item -ItemType Directory -Force |
os.makedirs(path, exist_ok=True) |
Remove-Item -Recurse -Force |
shutil.rmtree(path) |
Write-Host "text" |
print("text") |
Write-Error "text" |
print("text", file=sys.stderr) |
lxml vs stdlib
- Compile/init скрипты (строковая сборка): только stdlib
- DOM-скрипты (edit/validate/info):
lxmlсXMLParser(remove_blank_text=False)для сохранения whitespace - img-grid:
Pillowдля чтения PNG/JPEG и отрисовки сетки поверх изображения - Web-скрипты:
psutilдля работы с процессами Apache
Зависимости:
lxml>=4.9.0— ~25 DOM-скриптовPillow>=10.0.0—/img-gridpsutil>=5.9.0— 4 web-скрипта
Работа с BOM (UTF-8)
Кодек Python utf-8-sig — точный аналог New-Object System.Text.UTF8Encoding($true):
- Запись: добавляет BOM (EF BB BF)
- Чтение: убирает BOM автоматически
# Чтение (BOM убирается)
with open(path, 'r', encoding='utf-8-sig') as f:
content = f.read()
# Запись (BOM добавляется)
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
Параметр newline='' предотвращает двойные \r\n на Windows.
Сохранение XML с lxml
from lxml import etree
# Загрузка с сохранением whitespace
parser = etree.XMLParser(remove_blank_text=False)
tree = etree.parse(path, parser)
root = tree.getroot()
# Сохранение с BOM
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding='UTF-8')
# Fix declaration: etree пишет одинарные кавычки и uppercase encoding,
# PS1 XmlWriter пишет двойные кавычки и lowercase encoding
xml_bytes = xml_bytes.replace(
b"<?xml version='1.0' encoding='UTF-8'?>",
b'<?xml version="1.0" encoding="utf-8"?>')
# Trailing newline (PS1 XmlWriter добавляет, etree — нет)
if not xml_bytes.endswith(b"\n"):
xml_bytes += b"\n"
with open(path, 'wb') as f:
f.write(b'\xef\xbb\xbf') # BOM
f.write(xml_bytes)
Известные подводные камни
Namespace в XPath
lxml требует явный namespace map. В PS1 используется XmlNamespaceManager:
NSMAP = {'md': 'http://v8.1c.ru/8.3/MDClasses'}
node = root.find('.//md:ChildObjects/md:Form', NSMAP)
d5p1: для ссылочных типов
В DCS-файлах ссылочные типы используют d5p1:, не cfg::
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.XXX</v8:Type>
XML declaration кавычки и encoding
lxml/etree пишут <?xml version='1.0' encoding='UTF-8'?> (одинарные кавычки, uppercase). PS1 XmlWriter пишет <?xml version="1.0" encoding="utf-8"?> (двойные, lowercase). 1C принимает оба варианта, но одинарные кавычки — нестандартны. Замена всего declaration — в секции "Сохранение XML с lxml".
etree vs XmlDocument — различия сериализации
Python etree (lxml и stdlib) сериализует XML иначе, чем PS1 XmlDocument:
| Аспект | PS1 XmlDocument | Python etree | Влияние |
|---|---|---|---|
| Declaration кавычки | version="1.0" |
version='1.0' |
Нестандартные одинарные кавычки |
| Encoding case | encoding="utf-8" |
encoding='UTF-8' |
Косметическое |
| Self-closing space | <Tag /> |
<Tag/> |
Косметическое, 1C принимает оба |
| Trailing newline | Да | Нет | Расхождение при побайтовом сравнении |
| Unused xmlns | Сохраняет | Удаляет | Файл валиден, но отличается от канона |
| CR в text content | \r as-is |
entity |
Разный формат, одинаковый смысл |
| Пустые элементы | <Tag>\n</Tag> |
<Tag/> |
Косметическое |
Все эти различия обрабатываются normalizeXmlContent() в тест-раннере (только для --runtime python). PS1 тесты остаются строгими.
Hashtable vs dict — порядок итерации
PS1 @{} (Hashtable) итерирует ключи в порядке хэш-кодов. Python dict — в порядке вставки. Если порядок элементов влияет на вывод (присвоение индексов, генерация UUID), используйте sorted() в Python и | Sort-Object в PS1 для детерминизма.
Regex: (?i) inline flag в Python 3.11+
PS1: '^(?i)desc$' — работает. Python 3.11+: r'^(?i)desc$' — ошибка. Inline-флаг (?i) должен быть в начале строки паттерна: r'(?i)^desc$' или re.IGNORECASE.
Регистр: PS не различает его нигде, Python различает везде
Самая дорогая ловушка портирования, потому что молчаливая. В PowerShell регистр не значим во
всём, куда попадает пользовательский ввод: свойства объекта из ConvertFrom-Json, ключи
Hashtable, -eq/-contains, имена параметров, [ValidateSet]. В Python совпадение точное,
поэтому "CodeLength" вместо "codeLength" в py-порте просто не находится: навык печатает
[OK], а свойство в выход не попадает.
В py-портах ввод поэтому проходит через общие обёртки (реестр — в
tests/skills/check-inline-drift.mjs, семьи case-insensitive input: *):
args = ci_parse_args(parser) # имена параметров и значения choices — как ValidateSet
defn = ci_json(json.loads(json_text)) # словари DSL → CIDict: поиск без учёта регистра
enum_value_aliases = CIDict(enum_value_aliases) # словари, по которым ищут ввод пользователя
CIDict хранит ключи КАК ЕСТЬ — часть из них это имена объектов, попадающие в XML;
регистронезависим только поиск. В .ps1 этих обёрток нет и быть не должно.
Обёртка чинит поиск по словарю, но не сравнение строк: if k in valid_types в PS
регистронезависим (-contains), в Python нет. Такие места правятся точечно приведением к
одному регистру — и обязаны приводить значение к КАНОНУ списка, иначе прощённый регистр утечёт
в имя тега (в meta-compile "type":"catalog" так и делал <catalog>).
Обращение к отсутствующим свойствам
PS1 молча возвращает $null при обращении к несуществующему свойству (.empty на массиве). Python падает с AttributeError. Добавляйте isinstance() проверки при портировании.
Платформозависимые заметки
Скрипты db-* и web-* используют платформу 1С (Designer CLI, Apache) — работают только на Windows. Но синтаксических ошибок на других ОС не будет: скрипт корректно сообщит об отсутствии платформы.
Добавление нового навыка
Чеклист:
- Создать
.ps1скрипт - Создать
.pyскрипт с идентичными параметрами - В SKILL.md указать
powershell.exe -NoProfile -File ... .ps1(по умолчанию) - Скрипт переключения автоматически подхватит новый навык
Обновление существующего навыка
При доработке .ps1:
- Применить аналогичные изменения в
.py - Если затронуты inline-утилиты — править эталон и копировать во всех потребителей варианта
(см. ниже), затем прогнать
node tests/skills/check-inline-drift.mjs
Inline-утилиты — реестр
Списка «на глаз» здесь нет: он протухает молча. Источник истины — реестр внутри гарда:
node tests/skills/check-inline-drift.mjs --list # семья → эталон → список копий
node tests/skills/check-inline-drift.mjs # проверить, что копии не разошлись
node debug/inline-utils/scan-dupes.mjs # найти кандидатов, которых в реестре ещё нет
Модель: у семьи есть один или несколько вариантов, у варианта — навык-эталон и список копий. Копия обязана совпадать с эталоном своего варианта побайтово (с точностью до комментариев, docstring-ов и пробелов).
Несколько вариантов — нормально, если различие содержательное, и тогда у варианта обязано быть
поле why. Пример: esc_xml в form-* не экранирует кавычки (платформа их в тексте элемента не
экранирует, " ломает раундтрип), а в init-навыках экранирует — там он применяется к
значениям атрибутов. Вариант без why считается долгом и печатается как WARN.
Добавил новую общую утилиту — заведи семью в реестре, иначе гард сообщит, что навык содержит функцию, но в реестре не объявлен.