Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30803a0834 | |||
| f3f7a9b9e5 |
2
.github/workflows/release_skill.yaml
vendored
2
.github/workflows/release_skill.yaml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
|
||||
uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
|
||||
secrets:
|
||||
PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }}
|
||||
with:
|
||||
|
||||
@@ -215,6 +215,28 @@ scripts/
|
||||
|
||||
`health` 应委托 `collect_runtime_diagnostics`,不要在 `scripts/util/` 自建 `runtime_diagnostics.py`。
|
||||
|
||||
### 3.4 发布打包约束(PyArmor 与编码)
|
||||
|
||||
release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前发布链路可能运行在 **PyArmor 免费/试用模式**,因此有两条**发布硬约束**:
|
||||
|
||||
**单文件行数(仅 `scripts/**/*.py`)**
|
||||
|
||||
- 每个 `scripts/` 下 Python 文件必须 **小于 1000 行**(`>= 1000` 可能在 CI 加密阶段失败)。
|
||||
- 推荐单文件控制在 **900 行以内**;接近上限时应拆分模块。
|
||||
- 不要把完整 RPA 主流程、DOM 选择器、数据库、CLI、日志全部堆进一个大文件。
|
||||
- 正确做法是拆分到 `scripts/cli/`、`scripts/service/`、`scripts/db/`、`scripts/util/`。
|
||||
|
||||
`examples/` 里的 Python 示例也建议保持模块化,但**行数限制不作为 examples 的发布硬约束**;不要对 README、HTML、测试数据等文档/资源套用 PyArmor 行数规则。
|
||||
|
||||
**文本编码(UTF-8 without BOM)**
|
||||
|
||||
- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**。
|
||||
- Python 文件**绝对不能**带 UTF-8 BOM;否则 PyArmor/CI 可能报:`invalid non-printable character U+FEFF`。
|
||||
- Windows 编辑器容易保存成 UTF-8 BOM,提交前须确认文件开头无 `U+FEFF`。
|
||||
- 不要只写「UTF-8」,必须明确 **UTF-8 without BOM**。
|
||||
|
||||
本地可用 `python tests/run_tests.py release_packaging_constraints -v` 自检;默认必跑套件中的 `test_release_packaging_constraints.py` 会守护上述规则。
|
||||
|
||||
## 4. 开发一个新 skill 的标准步骤
|
||||
|
||||
下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。
|
||||
@@ -533,7 +555,7 @@ python scripts/main.py <your-command>
|
||||
`.github/workflows/release_skill.yaml` 默认引用:
|
||||
|
||||
```yaml
|
||||
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
|
||||
uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
|
||||
```
|
||||
|
||||
约定如下:
|
||||
@@ -666,6 +688,8 @@ uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yam
|
||||
- [ ] `release.ps1` 存在
|
||||
- [ ] `.github/workflows/release_skill.yaml` 存在
|
||||
- [ ] `python tests/run_tests.py -v` 全部通过
|
||||
- [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行)
|
||||
- [ ] 源码/文档/配置为 **UTF-8 without BOM**(Python 文件不得含 `U+FEFF`)
|
||||
- [ ] 没有新增默认必跑测试访问真实网络或浏览器
|
||||
- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
4. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
|
||||
5. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
|
||||
6. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
|
||||
7. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径与诊断约定
|
||||
7. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
|
||||
|
||||
Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)。
|
||||
用户市场说明见根目录 [`README.md`](../README.md),不要写进本目录。
|
||||
|
||||
@@ -108,6 +108,17 @@ RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均
|
||||
|
||||
## 编码与输出
|
||||
|
||||
- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**(不要只写「UTF-8」)。
|
||||
- Python 文件**绝对不能**带 UTF-8 BOM;否则 release CI 的 PyArmor 阶段可能报:`invalid non-printable character U+FEFF`。
|
||||
- Windows 编辑器容易保存成 UTF-8 BOM;提交前确认文件开头无 `U+FEFF`。
|
||||
- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装
|
||||
- 机读输出优先单行 JSON
|
||||
- 错误前缀建议统一 `ERROR:`
|
||||
|
||||
## 发布打包约束(`scripts/**/*.py`)
|
||||
|
||||
release workflow 加密 `scripts/` 源码时,可能运行在 PyArmor 免费/试用模式:
|
||||
|
||||
- `scripts/**/*.py` 单文件必须 **< 1000 行**(`>= 1000` 可能在 CI 失败);推荐 **< 900 行**。
|
||||
- 接近上限时拆分到 `cli/`、`service/`、`db/`、`util/`,不要把 RPA 主流程、选择器、DB、CLI 堆进单文件。
|
||||
- 此行数限制**仅适用于** `scripts/**/*.py`;不限制 README 等文档行数,也不把 examples 里的 HTML/README 纳入 PyArmor 行数约束。
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py);
|
||||
- SQLite 骨架:`task_logs` 创建幂等与仓储读写;
|
||||
- **adapter profile**:[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。
|
||||
- **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。
|
||||
|
||||
**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration(并由档位变量放行)。
|
||||
|
||||
@@ -224,6 +225,8 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
|
||||
- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py`
|
||||
- [ ] pytest **不会**误收集 `tests/` 下的 `.txt` / 日志 / 结果文件
|
||||
- [ ] `python tests/run_tests.py -v` 全部通过
|
||||
- [ ] `scripts/**/*.py` 单文件 < 1000 行(`test_release_packaging_constraints.py`)
|
||||
- [ ] 文本文件为 UTF-8 without BOM,无 `U+FEFF`(同上)
|
||||
|
||||
---
|
||||
|
||||
|
||||
243
release.ps1
243
release.ps1
@@ -1,3 +1,15 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Local git release for skill-template repo.
|
||||
|
||||
.DESCRIPTION
|
||||
Handles git commit / push / semver tag / push tag only.
|
||||
Encryption, ZIP packaging, and marketplace sync are done by CI
|
||||
(.github/workflows/release_skill.yaml → reusable-release-skill.yaml@main).
|
||||
|
||||
Requires: git, PowerShell 5+
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Prefix = "v",
|
||||
@@ -11,13 +23,228 @@ param(
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$sharedScript = Join-Path $scriptDir "..\..\jiangchang-platform-kit\tools\release.ps1"
|
||||
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
|
||||
|
||||
if (-not (Test-Path $sharedScript)) {
|
||||
throw "Shared release script not found: $sharedScript"
|
||||
function Remove-GitNoise {
|
||||
param([string[]]$Lines)
|
||||
return @($Lines | Where-Object {
|
||||
$_ -and ($_ -notmatch '^warning:\s+unable to access ')
|
||||
})
|
||||
}
|
||||
|
||||
& $sharedScript @PSBoundParameters
|
||||
exit $LASTEXITCODE
|
||||
function Invoke-Git {
|
||||
param([Parameter(Mandatory = $true)][string]$Args)
|
||||
Write-Host ">> git $Args" -ForegroundColor DarkGray
|
||||
& cmd /c "git $Args 2>&1" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git command failed: git $Args"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GitOutput {
|
||||
param([Parameter(Mandatory = $true)][string]$Args)
|
||||
$output = & cmd /c "git $Args 2>&1"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git command failed: git $Args"
|
||||
}
|
||||
return @(Remove-GitNoise -Lines @($output))
|
||||
}
|
||||
|
||||
function Test-Repo {
|
||||
$output = & cmd /c "git rev-parse --is-inside-work-tree 2>&1"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return $false
|
||||
}
|
||||
$clean = Remove-GitNoise -Lines @($output)
|
||||
return (($clean | Select-Object -First 1) -eq "true")
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
|
||||
return $b
|
||||
}
|
||||
|
||||
function Get-StatusPorcelain {
|
||||
$lines = @(Get-GitOutput "status --porcelain")
|
||||
return $lines
|
||||
}
|
||||
|
||||
function Parse-SemVerTag {
|
||||
param(
|
||||
[string]$Tag,
|
||||
[string]$TagPrefix
|
||||
)
|
||||
$escaped = [regex]::Escape($TagPrefix)
|
||||
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
|
||||
if (-not $m.Success) { return $null }
|
||||
|
||||
return [pscustomobject]@{
|
||||
Raw = $Tag
|
||||
Major = [int]$m.Groups[1].Value
|
||||
Minor = [int]$m.Groups[2].Value
|
||||
Patch = [int]$m.Groups[3].Value
|
||||
}
|
||||
}
|
||||
|
||||
function Get-NextTag {
|
||||
param([string]$TagPrefix)
|
||||
|
||||
$tags = Get-GitOutput "tag --list"
|
||||
$parsed = @()
|
||||
|
||||
foreach ($t in $tags) {
|
||||
$t = $t.Trim()
|
||||
if (-not $t) { continue }
|
||||
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
|
||||
if ($null -ne $obj) { $parsed += $obj }
|
||||
}
|
||||
|
||||
if ($parsed.Count -eq 0) {
|
||||
return "${TagPrefix}1.0.1"
|
||||
}
|
||||
|
||||
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
|
||||
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
|
||||
}
|
||||
|
||||
function Assert-SkillReleasePackagingSources {
|
||||
param([Parameter(Mandatory = $true)][string]$SkillRoot)
|
||||
|
||||
$scriptsDir = Join-Path $SkillRoot "scripts"
|
||||
$mainPy = Join-Path $scriptsDir "main.py"
|
||||
if (-not (Test-Path -LiteralPath $mainPy)) {
|
||||
return
|
||||
}
|
||||
|
||||
$text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||
if ([string]::IsNullOrWhiteSpace($text)) {
|
||||
return
|
||||
}
|
||||
|
||||
$need = @()
|
||||
if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' }
|
||||
if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' }
|
||||
if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' }
|
||||
if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' }
|
||||
if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' }
|
||||
if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' }
|
||||
if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' }
|
||||
if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' }
|
||||
|
||||
foreach ($p in ($need | Select-Object -Unique)) {
|
||||
$folder = Join-Path $scriptsDir $p
|
||||
if (-not (Test-Path -LiteralPath $folder)) {
|
||||
throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder"
|
||||
}
|
||||
}
|
||||
|
||||
$pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue)
|
||||
Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Ensure-CleanOrAutoCommit {
|
||||
param(
|
||||
[switch]$DoAutoCommit,
|
||||
[switch]$NeedClean,
|
||||
[switch]$IsDryRun,
|
||||
[string]$Msg
|
||||
)
|
||||
|
||||
$status = @(Get-StatusPorcelain)
|
||||
if ($status.Length -eq 0) { return }
|
||||
|
||||
if ($NeedClean) {
|
||||
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
|
||||
Remove-GitNoise -Lines @(& cmd /c "git status --short 2>&1") | ForEach-Object { Write-Host $_ }
|
||||
throw "Abort: dirty working tree."
|
||||
}
|
||||
|
||||
$commitMsg = $Msg
|
||||
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
|
||||
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
|
||||
}
|
||||
|
||||
if (-not $DoAutoCommit) {
|
||||
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($IsDryRun) {
|
||||
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
|
||||
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
Invoke-Git "add -A"
|
||||
Invoke-Git "commit -m `"$commitMsg`""
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
|
||||
|
||||
if (-not (Test-Repo)) {
|
||||
throw "Current directory is not a git repository."
|
||||
}
|
||||
|
||||
$branch = Get-CurrentBranch
|
||||
if ([string]::IsNullOrWhiteSpace($branch)) {
|
||||
throw "Unable to determine current branch."
|
||||
}
|
||||
|
||||
if ($branch -notin @("main", "master")) {
|
||||
throw "Current branch is '$branch'. Release is only allowed from main/master."
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "[DryRun] Would run: git fetch --tags --prune origin" -ForegroundColor Yellow
|
||||
} else {
|
||||
Invoke-Git "fetch --tags --prune origin"
|
||||
}
|
||||
|
||||
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
|
||||
|
||||
$skillRoot = (Get-GitOutput "rev-parse --show-toplevel" | Select-Object -First 1).Trim()
|
||||
if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) {
|
||||
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
|
||||
}
|
||||
|
||||
$null = Remove-GitNoise -Lines @(& cmd /c 'git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>&1')
|
||||
$hasUpstream = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if ($DryRun) {
|
||||
if ($hasUpstream) {
|
||||
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
if ($hasUpstream) {
|
||||
Invoke-Git "push"
|
||||
} else {
|
||||
Invoke-Git "push -u origin $branch"
|
||||
}
|
||||
}
|
||||
|
||||
$nextTag = Get-NextTag -TagPrefix $Prefix
|
||||
Write-Host "Next tag: $nextTag" -ForegroundColor Green
|
||||
|
||||
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
|
||||
if ($existing.Length -gt 0) {
|
||||
throw "Tag already exists: $nextTag"
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
|
||||
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
|
||||
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
|
||||
Invoke-Git "tag -a $nextTag -m `"$Message`""
|
||||
Invoke-Git "push origin $nextTag"
|
||||
|
||||
Write-Host "Release success: $nextTag" -ForegroundColor Green
|
||||
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ class TestDocsStandards(unittest.TestCase):
|
||||
uses_line = _extract_workflow_uses_line(text)
|
||||
self.assertIsNotNone(uses_line, "release_skill.yaml must contain a uses: line")
|
||||
self.assertIn(
|
||||
"admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml",
|
||||
"client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml",
|
||||
uses_line,
|
||||
)
|
||||
self.assertRegex(
|
||||
|
||||
165
tests/test_release_packaging_constraints.py
Normal file
165
tests/test_release_packaging_constraints.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""发布打包守护:PyArmor 单文件行数与 UTF-8 without BOM。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from _support import get_skill_root
|
||||
|
||||
SCRIPTS_MAX_LINES = 1000
|
||||
SCRIPTS_RECOMMENDED_MAX_LINES = 900
|
||||
|
||||
TEXT_EXTENSIONS = frozenset(
|
||||
{
|
||||
".py",
|
||||
".md",
|
||||
".yml",
|
||||
".yaml",
|
||||
".json",
|
||||
".toml",
|
||||
".ini",
|
||||
".txt",
|
||||
".ps1",
|
||||
".sh",
|
||||
}
|
||||
)
|
||||
|
||||
SPECIAL_TEXT_FILES = frozenset({".env.example", ".gitignore"})
|
||||
|
||||
SKIP_DIR_NAMES = frozenset(
|
||||
{
|
||||
".git",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
"rpa-artifacts",
|
||||
"videos",
|
||||
}
|
||||
)
|
||||
|
||||
BINARY_EXTENSIONS = frozenset(
|
||||
{
|
||||
".db",
|
||||
".sqlite",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".mp4",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_text_candidate(path: str) -> bool:
|
||||
basename = os.path.basename(path)
|
||||
if basename in SPECIAL_TEXT_FILES:
|
||||
return True
|
||||
ext = os.path.splitext(basename)[1].lower()
|
||||
if ext in BINARY_EXTENSIONS:
|
||||
return False
|
||||
return ext in TEXT_EXTENSIONS
|
||||
|
||||
|
||||
def _iter_text_files(skill_root: str) -> list[str]:
|
||||
found: list[str] = []
|
||||
for root, dirnames, filenames in os.walk(skill_root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
|
||||
for name in filenames:
|
||||
full = os.path.join(root, name)
|
||||
if _is_text_candidate(full):
|
||||
found.append(full)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _iter_scripts_py_files(skill_root: str) -> list[str]:
|
||||
scripts_dir = os.path.join(skill_root, "scripts")
|
||||
found: list[str] = []
|
||||
if not os.path.isdir(scripts_dir):
|
||||
return found
|
||||
for root, dirnames, filenames in os.walk(scripts_dir):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
|
||||
for name in filenames:
|
||||
if name.endswith(".py"):
|
||||
found.append(os.path.join(root, name))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
class TestReleasePackagingConstraints(unittest.TestCase):
|
||||
def test_scripts_py_files_under_pyarmor_line_limit(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
offenders: list[str] = []
|
||||
for path in _iter_scripts_py_files(skill_root):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
line_count = sum(1 for _ in f)
|
||||
if line_count >= SCRIPTS_MAX_LINES:
|
||||
rel = os.path.relpath(path, skill_root).replace("\\", "/")
|
||||
offenders.append(
|
||||
f"{rel}: {line_count} lines (limit < {SCRIPTS_MAX_LINES})"
|
||||
)
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
msg=(
|
||||
"PyArmor trial/free mode may fail when a single Python file reaches "
|
||||
f"{SCRIPTS_MAX_LINES} lines; split this file into smaller modules. "
|
||||
f"Recommended keep each file < {SCRIPTS_RECOMMENDED_MAX_LINES} lines. "
|
||||
f"Offenders: {offenders}"
|
||||
),
|
||||
)
|
||||
|
||||
def test_text_files_are_utf8_without_bom(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
bom_failures: list[str] = []
|
||||
decode_failures: list[str] = []
|
||||
feff_failures: list[str] = []
|
||||
|
||||
for path in _iter_text_files(skill_root):
|
||||
rel = os.path.relpath(path, skill_root).replace("\\", "/")
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
|
||||
if raw.startswith(b"\xef\xbb\xbf"):
|
||||
bom_failures.append(rel)
|
||||
continue
|
||||
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
decode_failures.append(f"{rel}: {exc}")
|
||||
continue
|
||||
|
||||
if "\ufeff" in text:
|
||||
feff_failures.append(rel)
|
||||
|
||||
self.assertEqual(
|
||||
bom_failures,
|
||||
[],
|
||||
msg=(
|
||||
"Files must be saved as UTF-8 without BOM (remove UTF-8 BOM prefix). "
|
||||
f"Offenders: {bom_failures}"
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
decode_failures,
|
||||
[],
|
||||
msg=(
|
||||
"Files must be valid UTF-8 without BOM. "
|
||||
f"Offenders: {decode_failures}"
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
feff_failures,
|
||||
[],
|
||||
msg=(
|
||||
"Files must not contain U+FEFF; save as UTF-8 without BOM. "
|
||||
f"Offenders: {feff_failures}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user