forked from b.peck/BAT
exchange work
This commit is contained in:
1039
exchangeResources/Exchange+Resources+Style+Guide.md
Normal file
1039
exchangeResources/Exchange+Resources+Style+Guide.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
exchangeResources/Exchange+Resources+Style+Guide.pdf
Normal file
BIN
exchangeResources/Exchange+Resources+Style+Guide.pdf
Normal file
Binary file not shown.
202
exchangeResources/PUBLISHING.md
Normal file
202
exchangeResources/PUBLISHING.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Publishing a resource to the Ignition Exchange
|
||||
|
||||
How to take a conforming Ignition project and get it onto the Exchange: what file
|
||||
format to upload, how to produce and verify it, and what to put in every field of
|
||||
the upload form.
|
||||
|
||||
Companion to [`exchange_lint.py`](exchange_lint.py), which tests whether a project
|
||||
follows the conventions, and to the
|
||||
[Exchange Resources Style Guide](Exchange+Resources+Style+Guide.md), which is the
|
||||
source for everything here.
|
||||
|
||||
---
|
||||
|
||||
## 1. What you upload
|
||||
|
||||
A **project export `.zip`** — the file Ignition produces from Designer's
|
||||
File → Export, not a gateway backup.
|
||||
|
||||
| File | Upload as | Notes |
|
||||
|---|---|---|
|
||||
| `<resource>.zip` | Package file | The project export. The main artifact. |
|
||||
| `README.md` (or PDF) | Package file | Separate upload — see the warning below. |
|
||||
| Tag export `.json` | Package file | Only if the resource needs tags/UDTs. |
|
||||
| Database dump `.sql` | Package file | Only if it needs a schema or seed data. |
|
||||
| Screenshots `.png` | Images & Screenshots | Optional, strongly encouraged. |
|
||||
|
||||
> **Never upload a gateway backup (`.gwbk`).** It contains your entire gateway —
|
||||
> datasource credentials, journal profiles, tags, every other project. It is the
|
||||
> wrong artifact and it leaks configuration into a public resource.
|
||||
|
||||
The Exchange accepts multiple package files, up to 10 MB each.
|
||||
|
||||
### The README will probably not be inside the zip
|
||||
|
||||
Ignition's project export serialises **recognised project resources** — each is a
|
||||
directory containing a `resource.json`. A loose `README.md` at the project root
|
||||
is not a project resource, so the export is expected to drop it.
|
||||
|
||||
This is reasoning from how the export works, not a verified observation. **Unzip
|
||||
your export and check** before uploading. Either way the guide tells you to
|
||||
upload documentation separately:
|
||||
|
||||
> **Note:** If you already have detailed documentation for your resource please
|
||||
> note it here and include the documentation file in the Package File upload.
|
||||
|
||||
Note also that `exchange_lint.py`'s `E-UP-DOCS` rule checks the working
|
||||
directory, not the shipped package. A passing lint does not prove the zip
|
||||
carries your docs.
|
||||
|
||||
---
|
||||
|
||||
## 2. Before you package
|
||||
|
||||
### Run the conformance checker
|
||||
|
||||
```bash
|
||||
python3 exchangeResources/exchange_lint.py <project-dir>
|
||||
```
|
||||
|
||||
Zero failures is the bar. Warnings are naming conventions the guide calls
|
||||
"encouraged, not required" — review them and decide, rather than silencing them
|
||||
by contorting the project. Document any you deliberately keep.
|
||||
|
||||
### Work the guide's own checklist
|
||||
|
||||
The guide names four pre-upload checks. Three are often N/A for a
|
||||
journal- or tag-only resource; the first never is:
|
||||
|
||||
1. **Check the project export.** Import the exported zip into a brand-new
|
||||
project and confirm it works. Then import it into a project that *already has
|
||||
content* — that is the case the namespacing rules exist for, and the only way
|
||||
to prove nothing collides.
|
||||
2. **Include the tags.** If the resource needs tags or UDTs, export them and
|
||||
verify the export covers everything the project binds to.
|
||||
3. **Don't forget database backups.** If it expects tables or seed data, include
|
||||
the dump and name the vendor (MySQL / MSSQL / PostgreSQL) in the description
|
||||
*and* in the install instructions.
|
||||
4. **Take notes while you test.** Those notes become the Custom Installation
|
||||
Instructions field. Readers range from beginner to intermediate.
|
||||
|
||||
### Strip anything internal
|
||||
|
||||
Search the project for company names, project codenames, internal hostnames,
|
||||
ticket numbers and placeholder text — in **user-visible strings**, not just
|
||||
comments. The linter does not check content, only names and structure.
|
||||
|
||||
```bash
|
||||
grep -rniE "yourcompany|internal|localhost|todo|fixme|demo only" <project-dir>
|
||||
```
|
||||
|
||||
Check rendered label text too; a `props.text` value is easy to miss in JSON.
|
||||
|
||||
---
|
||||
|
||||
## 3. Produce the export
|
||||
|
||||
**Designer** — File → Export, select the project.
|
||||
**Gateway web UI** — Config → Projects → *project* → Export.
|
||||
|
||||
Both produce the same zip. If your repo stores projects as unpacked files
|
||||
(gateway-as-files), there is no zip in the repo — you must generate one.
|
||||
|
||||
---
|
||||
|
||||
## 4. Verify the export
|
||||
|
||||
```bash
|
||||
unzip -l <resource>.zip # inventory
|
||||
unzip -o <resource>.zip -d /tmp/verify-export # extract
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- **File count** matches the project on disk (minus anything the export legitimately drops).
|
||||
- **Every view, style class and script module** you expect is present.
|
||||
- **`README.md` presence** — confirm, do not assume.
|
||||
- **No stray files**: `.resources/` (gateway-owned cache), `__pycache__`, editor backups.
|
||||
- **No credentials** anywhere in the archive.
|
||||
|
||||
Then do the real test: import it into a populated project and click through.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fill in the upload form
|
||||
|
||||
Fields in the order the Exchange presents them.
|
||||
|
||||
### Overview
|
||||
|
||||
| Field | What to put |
|
||||
|---|---|
|
||||
| **Visibility** | `Public` unless it is genuinely private. |
|
||||
| **Title** | Short, Title Case, says what it does. Usually matches the project title. |
|
||||
| **Tagline** | One sentence. Must **not** repeat the title. |
|
||||
| **Description** | What it does, how it is meant to be used, who benefits, relevant industries. |
|
||||
| **Resource Type** | Closest match from the dropdown, plus a skill level reflecting install/use complexity. |
|
||||
| **Background Image** | Optional colour/design for the resource card. |
|
||||
| **Category** | **Up to 3.** Chosen from: Alarming, Analytics, Connectivity, Dashboard, Diagnostics, Edge Computing, Enterprise, HMI, IIOT/MQTT, MES, Maintenance, Mapping/GIS, Mobile, Monitoring, Reporting, SCADA, Scripting, Security, Simulation, Trending/Charting, Utility. |
|
||||
| **Contact the Developer** | Optional. Your address is never shown; you receive the question plus the asker's address. |
|
||||
| **Tags** | **Up to 10** search keywords. |
|
||||
| **Images & Screenshots** | Optional but highly encouraged — shows what the title and description cannot. |
|
||||
|
||||
### Package
|
||||
|
||||
| Field | What to put |
|
||||
|---|---|
|
||||
| **Version** | `1.0.0` for a first release; later versions auto-increment. **Do not** put the version in the project name. |
|
||||
| **Ignition Platform** | The **minimum** version required. The guide: *"If unsure, select the Ignition platform version in which the package was developed with."* Do not claim a floor you have not tested. |
|
||||
| **Release Tagline** | Short summary of this version's changes. Required even for the first release. |
|
||||
| **Release Notes** | Fuller explanation of what changed and how it affects users. |
|
||||
| **Required Modules** | Any Inductive Automation / Cirrus Link / Sepasoft modules needed. |
|
||||
| **Maker Edition** | Tick if it runs on Maker. Unavailable if you require modules Maker does not support. |
|
||||
| **Other Requirements** | External or custom prerequisites, one per line. Blank if none. |
|
||||
| **Package Files** | The zip, plus docs / tags / SQL as applicable. |
|
||||
| **Custom Installation Instructions** | Step-by-step setup. Assume a beginner. If you shipped separate documentation, say so here and include the file. |
|
||||
|
||||
The Exchange **auto-generates a readme** from your Description and Custom
|
||||
Installation Instructions, so write both properly.
|
||||
|
||||
### Verifying your minimum-version claim
|
||||
|
||||
Spin up a throwaway gateway at the version you intend to declare and load the
|
||||
project into it. Use a **copy** of the project files — an older gateway rewrites
|
||||
`resource.json` metadata and leaves root-owned `.resources/` artifacts behind.
|
||||
|
||||
A clean load proves resources parse. It does **not** prove screens render: a
|
||||
component property added in a later version fails at render time, not load time.
|
||||
Open the client and look.
|
||||
|
||||
---
|
||||
|
||||
## 6. Worked example — Alarm Analysis Dashboard
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Title | Alarm Analysis Dashboard |
|
||||
| Tagline | ISA-18.2 alarm performance metrics straight from the alarm journal |
|
||||
| Resource Type | Perspective View — Intermediate |
|
||||
| Category | Alarming, Analytics, Dashboard |
|
||||
| Tags | alarms, ISA-18.2, alarm journal, MTTA, MTTR, bad actors, alarm rate, KPI, dashboard, perspective |
|
||||
| Version | 1.0.0 |
|
||||
| Ignition Platform | 8.1.20 |
|
||||
| Required Modules | Perspective |
|
||||
| Maker Edition | Compatible |
|
||||
| Other Requirements | An alarm journal profile with data |
|
||||
| Package Files | `alarm-analysis.zip`, `README.md` |
|
||||
|
||||
Needs no tag export and no database dump — it reads the journal through
|
||||
`system.alarm.queryJournal` / `queryStatus` with no journal name, datasource or
|
||||
tag dependency. That portability is what the guide's structure rules produce.
|
||||
|
||||
---
|
||||
|
||||
## 7. After publishing
|
||||
|
||||
- **Updates** are new package versions on the same resource, not new resources.
|
||||
Keep the namespace stable so users' imports upgrade in place.
|
||||
- **Never rename the namespace folders** between versions — that turns an upgrade
|
||||
into a duplicate set of resources in every user's project.
|
||||
- Re-run `exchange_lint.py` before every release.
|
||||
- Non-English resources: the guide encourages shipping a language dropdown with
|
||||
English alongside, to widen access.
|
||||
156
exchangeResources/README.md
Normal file
156
exchangeResources/README.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Ignition Exchange resources
|
||||
|
||||
Getting an Ignition project ready to publish on the Ignition Exchange means
|
||||
following Inductive Automation's naming and structure conventions — mainly so
|
||||
that when someone imports your resource into a project they already have, nothing
|
||||
of theirs gets overwritten.
|
||||
|
||||
These files help you do that.
|
||||
|
||||
| File | What it's for |
|
||||
|---|---|
|
||||
| **[`exchange_lint.py`](exchange_lint.py)** | Check your project against the conventions. Start here. |
|
||||
| **[`PUBLISHING.md`](PUBLISHING.md)** | Package and upload it, once it passes. |
|
||||
| [`Exchange+Resources+Style+Guide.md`](Exchange+Resources+Style+Guide.md) | The official guide, v1.1.0. Every rule traces back to it. |
|
||||
|
||||
**The path: check your project → fix what it reports → follow `PUBLISHING.md`.**
|
||||
|
||||
---
|
||||
|
||||
## Checking your project
|
||||
|
||||
```bash
|
||||
python3 exchange_lint.py /path/to/YourProject
|
||||
```
|
||||
|
||||
Nothing to install — Python 3.8+ and the standard library.
|
||||
|
||||
Point it at any of these:
|
||||
|
||||
- your project folder
|
||||
- a folder containing several projects (a gateway's `projects/` directory, an
|
||||
unpacked gateway backup, your repo)
|
||||
- a project export `.zip`
|
||||
- nothing, to scan the current directory
|
||||
|
||||
There's no config file. It works out your resource name from the namespace your
|
||||
project already uses, and picks up tag exports and `.sql` dumps sitting alongside.
|
||||
|
||||
---
|
||||
|
||||
## Reading the report
|
||||
|
||||
```
|
||||
Ignition Exchange Style Guide v1.1.0 — conformance report
|
||||
|
||||
project: my-dashboard resource: MyDashboard
|
||||
|
||||
Resource Structure
|
||||
FAIL E-NS-ROOT script-python/myutils: project library package
|
||||
'myutils' is not under exchange/<resource>/; importing
|
||||
would land scripts in the host project's namespace
|
||||
|
||||
Perspective
|
||||
warn E-COMP-NAME views/Exchange/MyDashboard/Home: component 'headerRow'
|
||||
should be PascalCase
|
||||
|
||||
summary: 1 failure(s), 12 warning(s); 21 of 38 rules exercised
|
||||
```
|
||||
|
||||
Each finding gives you the file, the problem, and why it matters.
|
||||
|
||||
### FAIL — fix these before publishing
|
||||
|
||||
Either the guide says *must*, or the rule protects your resource from damaging
|
||||
someone else's project on import: resource namespacing, session properties,
|
||||
Vision client tags, and the project-name character set.
|
||||
|
||||
### warn — your call
|
||||
|
||||
These are naming conventions. The guide is explicit that they're *"encouraged,
|
||||
however these conventions are not required"*, and that consistency matters more
|
||||
than which convention you choose. Read them, decide, move on. Add `--strict` if
|
||||
you'd rather treat them as failures.
|
||||
|
||||
### "21 of 38 rules exercised"
|
||||
|
||||
How many rules actually applied to your project. If you have no database, no tags
|
||||
and no named queries, those rules never ran — so a pass on a small project isn't
|
||||
mistaken for a pass on everything.
|
||||
|
||||
**Exit codes:** `0` clean · `1` findings · `2` bad invocation.
|
||||
|
||||
---
|
||||
|
||||
## What gets checked
|
||||
|
||||
38 rules. Run `python3 exchange_lint.py --list-rules` for the full list with
|
||||
descriptions.
|
||||
|
||||
| Area | What it looks at |
|
||||
|---|---|
|
||||
| **Resource structure** | Everything namespaced under `Exchange/<Resource>/` or `exchange/<resource>/` — the guide's one hard *must*, and what keeps your import from overwriting the host project |
|
||||
| **Projects** | URL-friendly project name, human-friendly title, no version suffix |
|
||||
| **Perspective** | Naming of views, components, properties, pages and style classes; where style classes live; message-handler namespacing |
|
||||
| **Variable storage** | No session custom properties, no Vision client tags — both break a clean import |
|
||||
| **Code** | Tab indentation, camelCase functions and variables, PascalCase classes, docstrings, comment style, project-library layout |
|
||||
| **Loggers** | `exchange.resourceName.LoggerName` hierarchy |
|
||||
| **Database** | `ex_` table prefix, snake_case, `id` primary key, `<table>_id` foreign keys |
|
||||
| **Tags · Vision · Named queries · WebDev · Translations** | Naming conventions for each |
|
||||
| **Upload readiness** | Description filled in, installation documentation present |
|
||||
|
||||
## What it can't check for you
|
||||
|
||||
A clean report doesn't mean ready to publish. Still on you:
|
||||
|
||||
- **What's written on screen.** It reads names and structure, never label text. A
|
||||
visible string like *"Acme Internal — do not distribute"* passes silently.
|
||||
Search your user-visible strings before you publish.
|
||||
- **That references still resolve.** It doesn't confirm an embedded view path,
|
||||
style class or script call points at something real. If you've just renamed
|
||||
things to fit the conventions, verify the project still works.
|
||||
- **That it runs.** Nothing is executed or rendered. Load it on a gateway and
|
||||
open the screens.
|
||||
- **Your export.** The documentation rule checks your working folder, not the
|
||||
zip you upload. See [`PUBLISHING.md`](PUBLISHING.md).
|
||||
|
||||
---
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--resource NAME` | Set your resource name, if the inferred one is wrong |
|
||||
| `--strict` | Treat every warning as a failure |
|
||||
| `--ignore E-PY-VAR,E-COMP-NAME` | Suppress specific rules |
|
||||
| `-v` | Show every occurrence instead of collapsing per rule |
|
||||
| `--max-per-rule N` | Change the collapse limit (default 8) |
|
||||
| `--json` | Machine-readable output, for CI |
|
||||
| `--tags` / `--sql` | Point at tag exports or SQL dumps explicitly |
|
||||
| `--list-rules` | Print the full rule table and exit |
|
||||
|
||||
---
|
||||
|
||||
## Findings that need context
|
||||
|
||||
Three rules produce messages that can be confusing on first read.
|
||||
|
||||
**`E-NS-ROOT` — why capital `Exchange/` sometimes and lowercase `exchange/` other
|
||||
times?** That's the guide's convention, not a typo. Views, windows, templates,
|
||||
named queries, reports, SFCs, transaction groups, alarm pipelines and tags go
|
||||
under `Exchange/`. Style classes, the project library, WebDev sources and images
|
||||
go under `exchange/`. Using the wrong root is a `FAIL`; using the right root with
|
||||
the wrong capitalisation is just a warning (`E-NS-CASE`).
|
||||
|
||||
**`E-STYLE-HIER` may flag a class you're sharing deliberately.** It decides where
|
||||
a style class belongs by finding which views reference it — but it only sees
|
||||
class names written as plain text. If you build one in a binding, like
|
||||
`'exchange/my-resource/priority/' + level.lower()`, it can't see that usage and
|
||||
may report a shared class as used by only one view. If that's your situation, the
|
||||
warning is safe to ignore.
|
||||
|
||||
**`E-PAGE-ROOT` isn't in the guide.** It's an added caution: mapping a page to
|
||||
`/` means your resource takes over the root URL of whatever project it's imported
|
||||
into. Worth knowing before you decide — the trade-off is that a resource with no
|
||||
`/` page can't be launched from the gateway's Perspective project list, only from
|
||||
its full page URL.
|
||||
94
exchangeResources/claude-skills/INSTALL.md
Normal file
94
exchangeResources/claude-skills/INSTALL.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Installing these skills into the `ignition` plugin
|
||||
|
||||
Three Claude Code skills that package the Exchange tooling in this folder so
|
||||
other people get it automatically, without knowing it exists.
|
||||
|
||||
They are staged here rather than written directly into `AI-Tools` because that
|
||||
repo was read-only for this work. Nothing below has been applied there yet.
|
||||
|
||||
## Where they go
|
||||
|
||||
Into the **existing `ignition` plugin**, not a new one. That follows the
|
||||
marketplace's own rule:
|
||||
|
||||
> Plugins are split by **domain**, not by repo it came from. […] A fourth plugin
|
||||
> with one skill in it is worse than putting that skill somewhere slightly
|
||||
> imperfect: it's another thing to install and another manifest to maintain.
|
||||
|
||||
Exchange publishing is Ignition-domain work, and `ignition` already exists.
|
||||
|
||||
```bash
|
||||
cp -r ignition-exchange-conformance \
|
||||
ignition-exchange-convert \
|
||||
ignition-exchange-publish \
|
||||
~/git/AI-Tools/plugins/ignition/skills/
|
||||
```
|
||||
|
||||
Resulting layout:
|
||||
|
||||
```
|
||||
plugins/ignition/skills/
|
||||
ignition-gateway/
|
||||
ignition-trial-reset/
|
||||
ignition-exchange-conformance/ SKILL.md exchange_lint.py test_exchange_lint.py
|
||||
ignition-exchange-convert/ SKILL.md verify_refs.py parity.py
|
||||
ignition-exchange-publish/ SKILL.md
|
||||
```
|
||||
|
||||
## Then: bump the plugin version
|
||||
|
||||
`plugins/ignition/.claude-plugin/plugin.json` — `0.1.0` → `0.2.0`, and widen the
|
||||
description, which currently only mentions the gateway API and trial reset:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ignition",
|
||||
"description": "Ignition 8.3 gateway skills. HTTP API authentication and endpoint discovery, trial-expiry detection and reset on unlicensed gateways, and Ignition Exchange publishing — conformance checking, converting a project into Exchange shape, and packaging for upload.",
|
||||
"author": { "name": "Brendan Peck", "email": "b.peck@prime-controls.com" },
|
||||
"version": "0.2.0"
|
||||
}
|
||||
```
|
||||
|
||||
`.claude-plugin/marketplace.json` carries a second copy of that description —
|
||||
update it to match, or the marketplace listing will understate the plugin.
|
||||
|
||||
## Then: the README rows
|
||||
|
||||
Under `### ignition`, the skill table becomes:
|
||||
|
||||
```markdown
|
||||
| Skill | Loads when you're... |
|
||||
| --- | --- |
|
||||
| `ignition-gateway` | calling an Ignition 8.3 gateway's HTTP API (`/data/api/v1/...`) |
|
||||
| `ignition-trial-reset` | debugging bad-quality tags, or resetting a trial gateway |
|
||||
| `ignition-exchange-conformance` | checking whether a project is ready for the Ignition Exchange |
|
||||
| `ignition-exchange-convert` | turning an existing project into a publishable Exchange resource |
|
||||
| `ignition-exchange-publish` | packaging and uploading a resource to the Exchange |
|
||||
```
|
||||
|
||||
Suggested paragraph to follow it:
|
||||
|
||||
> The three `ignition-exchange-*` skills cover the publishing pipeline end to
|
||||
> end. `-conformance` runs a 38-rule checker against the Exchange Resources Style
|
||||
> Guide and, just as usefully, says what it cannot check. `-convert` carries the
|
||||
> migration order and two scripts that catch what a conformance checker cannot
|
||||
> see: dangling references after a mass rename, and features silently dropped.
|
||||
> `-publish` is the upload form, field by field.
|
||||
|
||||
## Verify after copying
|
||||
|
||||
```bash
|
||||
cd ~/git/AI-Tools
|
||||
python3 plugins/ignition/skills/ignition-exchange-conformance/test_exchange_lint.py
|
||||
python3 -c "import json;[json.load(open(p)) for p in ['.claude-plugin/marketplace.json','plugins/ignition/.claude-plugin/plugin.json']];print('manifests parse')"
|
||||
/plugin marketplace add ~/git/AI-Tools # local path works while developing
|
||||
```
|
||||
|
||||
## Keeping the copies in sync
|
||||
|
||||
`exchange_lint.py` and `test_exchange_lint.py` now exist twice: in
|
||||
`exchangeResources/` (the working copy, used by this repo) and inside the skill
|
||||
(the distributable). `verify_refs.py` and `parity.py` live **only** in the skill.
|
||||
|
||||
If you change a rule, update both — or make `exchangeResources/` a symlink to the
|
||||
skill copy and keep one source of truth.
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: ignition-exchange-conformance
|
||||
description: Check whether an Ignition project follows Inductive Automation's Exchange Resources Style Guide before publishing it to the Ignition Exchange — resource namespacing, Perspective/Vision/Python naming, session-property and client-tag prohibitions, database and logger conventions. Use when asked whether a project is Exchange-ready, when reviewing a project intended for the Exchange, or before packaging one for upload. Bundles a stdlib-only checker (exchange_lint.py) with 38 rules traceable to guide sections. Not for converting a project into that shape (use ignition-exchange-convert) or for packaging and upload (use ignition-exchange-publish).
|
||||
---
|
||||
|
||||
# Ignition Exchange conformance
|
||||
|
||||
Tests an Ignition project against the **Exchange Resources Style Guide v1.1.0**.
|
||||
|
||||
Run `exchange_lint.py` (next to this file) rather than checking by eye — the rules
|
||||
are numerous, mechanical, and easy to half-apply.
|
||||
|
||||
```bash
|
||||
python3 skills/ignition-exchange-conformance/exchange_lint.py /path/to/Project
|
||||
```
|
||||
|
||||
Stdlib only, Python 3.8+. Accepts a project folder, a folder containing several
|
||||
projects (a gateway's `projects/` directory, an unpacked backup, a repo), or a
|
||||
project export `.zip`. Infers the resource name from the namespace the project
|
||||
already uses — no configuration.
|
||||
|
||||
Exit codes: `0` clean · `1` findings · `2` bad invocation.
|
||||
|
||||
## The one rule that is actually mandatory
|
||||
|
||||
The guide states exactly one `must`:
|
||||
|
||||
> Views, Styles, Scripts, etc. **must** all be contained within an appropriately
|
||||
> named folder. This allows easy imports into existing projects, where those
|
||||
> project resources won't overlap, interfere, or overwrite existing parts of a
|
||||
> user's project.
|
||||
|
||||
Everything else — every naming convention — the guide explicitly calls optional:
|
||||
|
||||
> We encourage you to follow the naming conventions outlined here, however
|
||||
> **these conventions are not required**.
|
||||
|
||||
So `exchange_lint.py` reports **FAIL** only for namespacing, the storage
|
||||
prohibitions, and the project-name character set. Naming is **warn**. Don't
|
||||
report warnings as blockers, and don't contort a project to silence them —
|
||||
a consistent house style is defensible and the guide says so.
|
||||
|
||||
## Namespace roots — two spellings, both correct
|
||||
|
||||
Per the guide's Project Browser screenshots:
|
||||
|
||||
| Under `Exchange/<ResourceName>/` | Under `exchange/<resource-name>/` |
|
||||
|---|---|
|
||||
| Views, Windows, Templates | Style classes |
|
||||
| Named queries, Reports | Project library (scripts) |
|
||||
| SFCs, Transaction groups | WebDev sources |
|
||||
| Alarm pipelines, Tags | Images |
|
||||
|
||||
Uppercase roots take Title Case or PascalCase names; lowercase roots take
|
||||
kebab-case — **except the project library**, which is code-facing and uses
|
||||
lowercase or camelCase (`exchange.resourceName.scriptName`), matching the
|
||||
logger convention `exchange.resourceName.LoggerName`.
|
||||
|
||||
## The storage prohibitions
|
||||
|
||||
Both are FAILs because both break a clean import, and both are easy to miss:
|
||||
|
||||
- **Perspective session custom properties.** Importing merges them into the host
|
||||
project's session props. Use view `params`/`custom` plus, for genuinely
|
||||
non-reactive cross-session state, `system.util.getGlobals()['exchange']['resourceName']`.
|
||||
- **Vision client tags.** Same reasoning.
|
||||
|
||||
## What the checker cannot tell you
|
||||
|
||||
A clean report is not "ready to publish". Four gaps, all of which have bitten:
|
||||
|
||||
1. **Content.** It reads names and structure, never label text. A visible string
|
||||
like `"Acme Internal — do not distribute"` passes silently. Always sweep
|
||||
user-visible strings separately:
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import json, glob, re
|
||||
bad = re.compile(r"internal|confidential|do not|contest|demo only|todo|fixme", re.I)
|
||||
for f in glob.glob("<project>/com.inductiveautomation.perspective/views/**/view.json", recursive=True):
|
||||
v = json.load(open(f))
|
||||
def walk(n):
|
||||
yield n
|
||||
for c in (n.get("children") or []): yield from walk(c)
|
||||
for n in walk(v.get("root") or {}):
|
||||
t = (n.get("props") or {}).get("text")
|
||||
if isinstance(t, str) and bad.search(t): print(f, repr(t))
|
||||
PY
|
||||
```
|
||||
2. **Referential integrity.** It does not check that embedded view paths, style
|
||||
classes or script calls resolve. See [`ignition-exchange-convert`](../ignition-exchange-convert/SKILL.md).
|
||||
3. **Runtime.** Nothing is executed or rendered. Load it on a gateway.
|
||||
4. **The shipped package.** `E-UP-DOCS` checks the working directory, not the
|
||||
export zip. See [`ignition-exchange-publish`](../ignition-exchange-publish/SKILL.md).
|
||||
|
||||
## Findings that need interpretation
|
||||
|
||||
**`E-STYLE-HIER` false positives.** It decides where a style class belongs by
|
||||
finding which views reference it, reading only literal `props.style.classes`
|
||||
strings. A class name built in a binding —
|
||||
`'exchange/my-resource/priority/' + level.lower()` — is invisible, so a shared
|
||||
class gets reported as single-use. Verify before acting; do not duplicate style
|
||||
classes to silence it.
|
||||
|
||||
**`E-PAGE-ROOT` is not in the guide.** It is an added caution: a page mapped to
|
||||
`/` takes over the root URL of any project the resource is imported into. Real
|
||||
trade-off — a resource with no `/` page cannot be launched from the gateway's
|
||||
Perspective project list, only from its full page URL
|
||||
(`/data/perspective/client/<project>/<page>`). Flag the trade-off; let the user
|
||||
decide.
|
||||
|
||||
**`E-PY-VAR` volume.** A project written in idiomatic snake_case produces
|
||||
hundreds of warnings, since the guide wants camelCase for variables and
|
||||
functions (deliberately diverging from PEP-8; `UPPER_SNAKE` constants are
|
||||
exempt). Offer `--ignore E-PY-VAR,E-PY-FUNC` rather than a mass rename, unless
|
||||
the user wants full conformance.
|
||||
|
||||
## Useful flags
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--strict` | Warnings become failures |
|
||||
| `--ignore E-PY-VAR,E-COMP-NAME` | Suppress rules |
|
||||
| `-v` | Every occurrence (default collapses at 8 per rule) |
|
||||
| `--json` | Machine-readable, for CI |
|
||||
| `--list-rules` | All 38 rules with descriptions |
|
||||
|
||||
## Reporting results
|
||||
|
||||
The summary reads `N of 38 rules exercised`. Quote it. A project with no
|
||||
database, tags or named queries legitimately leaves those rules unexercised, and
|
||||
a narrow pass should never be presented as a broad one.
|
||||
|
||||
## Self-test
|
||||
|
||||
`test_exchange_lint.py` builds a conforming and a non-conforming project in a
|
||||
temp dir, asserts the checker is silent on the first and fires the expected rule
|
||||
id on the second, and asserts every declared rule is reachable in the source.
|
||||
Run it after changing any rule.
|
||||
|
||||
```bash
|
||||
python3 skills/ignition-exchange-conformance/test_exchange_lint.py
|
||||
```
|
||||
|
||||
## Provenance
|
||||
|
||||
Rules derived from Exchange Resources Style Guide v1.1.0. Checker exercised
|
||||
against four real Perspective projects and validated on Ignition **8.1.20** and
|
||||
**8.3.7** gateways (2026-09-15). Where a rule is an inference rather than guide
|
||||
text it says so in its description — currently only `E-PAGE-ROOT`.
|
||||
1243
exchangeResources/claude-skills/ignition-exchange-conformance/exchange_lint.py
Executable file
1243
exchangeResources/claude-skills/ignition-exchange-conformance/exchange_lint.py
Executable file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-test for exchange_lint.py.
|
||||
|
||||
Builds two throwaway Ignition projects in a temp directory -- one that follows
|
||||
the Exchange Resources Style Guide and one that breaks a known rule per check --
|
||||
then asserts the checker stays silent on the first and fires the expected rule
|
||||
id on the second. This is what keeps the rules honest: a rule that cannot fail
|
||||
and a rule that always fires both show up here.
|
||||
|
||||
Run directly (python3 test_exchange_lint.py) or under pytest.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import exchange_lint as el # noqa: E402
|
||||
|
||||
RESOURCE = "SampleResource"
|
||||
PERSP = el.PERSPECTIVE
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ fixtures
|
||||
|
||||
def write(path, text):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def write_json(path, obj):
|
||||
write(path, json.dumps(obj, indent=2))
|
||||
|
||||
|
||||
RESOURCE_JSON = {"scope": "G", "version": 1, "restricted": False,
|
||||
"overridable": True, "files": ["view.json"], "attributes": {}}
|
||||
|
||||
CLEAN_CODE = '''"""Sample library module."""
|
||||
|
||||
|
||||
def formatCount(rawCount):
|
||||
"""Return rawCount rendered for display."""
|
||||
# pad small values so the column stays aligned
|
||||
displayValue = "%d" % rawCount
|
||||
return displayValue
|
||||
'''
|
||||
|
||||
DIRTY_CODE = '''def format_count(raw_count):
|
||||
#no space after hash
|
||||
display_value = "%d" % raw_count
|
||||
return display_value
|
||||
'''
|
||||
|
||||
|
||||
def build_project(root, name, clean=True):
|
||||
"""Create a minimal Perspective project; clean=False breaks one rule per check."""
|
||||
proj = os.path.join(root, name)
|
||||
ns_upper = "Exchange/%s" % RESOURCE if clean else "MyCompany"
|
||||
ns_lower = "exchange/sample-resource" if clean else "MyCompany"
|
||||
lib_pkg = "exchange/sampleresource" if clean else "MyCompany"
|
||||
|
||||
write_json(os.path.join(proj, "project.json"), {
|
||||
"title": "Sample Resource" if clean else "",
|
||||
"description": "A sample Exchange resource." if clean else "",
|
||||
"enabled": True, "inheritable": False, "parent": "",
|
||||
})
|
||||
|
||||
view_dir = os.path.join(proj, PERSP, "views", *ns_upper.split("/"), "Dashboard")
|
||||
write_json(os.path.join(view_dir, "resource.json"), RESOURCE_JSON)
|
||||
write_json(os.path.join(view_dir, "view.json"), {
|
||||
"params": {"startMs": 0} if clean else {"start_ms": 0},
|
||||
"custom": {},
|
||||
"root": {
|
||||
"type": "ia.container.flex",
|
||||
"meta": {"name": "root"},
|
||||
"props": {"style": {"classes": "%s/dashboard/card" % ns_lower
|
||||
if clean else "%s/Card" % ns_lower}},
|
||||
"children": [{
|
||||
"type": "ia.display.label",
|
||||
"meta": {"name": "TitleLabel" if clean else "titleLabel"},
|
||||
"custom": {"labelText": ""} if clean else {"label_text": ""},
|
||||
"props": {},
|
||||
"scripts": {"messageHandlers": [{
|
||||
"messageType": ("exchange.sampleResource.refresh"
|
||||
if clean else "refresh")}]},
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
style_dir = os.path.join(proj, PERSP, "style-classes", *ns_lower.split("/"),
|
||||
*(("dashboard", "card") if clean else ("Card",)))
|
||||
write_json(os.path.join(style_dir, "resource.json"),
|
||||
dict(RESOURCE_JSON, files=["style.json"]))
|
||||
write_json(os.path.join(style_dir, "style.json"), {"style": {}})
|
||||
|
||||
write_json(os.path.join(proj, PERSP, "page-config", "config.json"),
|
||||
{"pages": {"/sample-resource" if clean else "/": {
|
||||
"viewPath": "%s/Dashboard" % ns_upper}}, "sharedDocks": {}})
|
||||
|
||||
write_json(os.path.join(proj, PERSP, "session-props", "props.json"),
|
||||
{"custom": {} if clean else {"MyCompany": {"selectedTab": 0}},
|
||||
"props": {}})
|
||||
|
||||
code_dir = os.path.join(proj, "ignition", "script-python",
|
||||
*lib_pkg.split("/"), "fmt")
|
||||
write_json(os.path.join(code_dir, "resource.json"),
|
||||
dict(RESOURCE_JSON, scope="A", files=["code.py"]))
|
||||
write(os.path.join(code_dir, "code.py"), CLEAN_CODE if clean else DIRTY_CODE)
|
||||
|
||||
if clean:
|
||||
write(os.path.join(proj, "README.md"), "# Sample Resource\n\nInstall steps.\n")
|
||||
else:
|
||||
vis_ctags = os.path.join(proj, el.VISION, "client-tags")
|
||||
write_json(os.path.join(vis_ctags, "resource.json"),
|
||||
dict(RESOURCE_JSON, files=["data.bin"]))
|
||||
write(os.path.join(vis_ctags, "data.bin"), "x")
|
||||
return proj
|
||||
|
||||
|
||||
SQL_CLEAN = """
|
||||
CREATE TABLE ex_sr_contacts (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
contact_name VARCHAR(64),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE ex_sr_calls (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
ex_sr_contacts_id INT,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (ex_sr_contacts_id) REFERENCES ex_sr_contacts(id)
|
||||
);
|
||||
"""
|
||||
|
||||
SQL_DIRTY = """
|
||||
CREATE TABLE Contacts (
|
||||
contactId INT NOT NULL AUTO_INCREMENT,
|
||||
ContactName VARCHAR(64),
|
||||
PRIMARY KEY (contactId)
|
||||
);
|
||||
CREATE TABLE ex_sr_calls (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
contact INT,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (contact) REFERENCES Contacts(contactId)
|
||||
);
|
||||
"""
|
||||
|
||||
TAGS_CLEAN = {"tags": [{"name": "Exchange", "tagType": "Folder", "tags": [
|
||||
{"name": RESOURCE, "tagType": "Folder", "tags": [
|
||||
{"name": "AlarmCount", "tagType": "AtomicTag", "valueSource": "memory"}]}]}]}
|
||||
|
||||
TAGS_DIRTY = {"tags": [{"name": "my_company", "tagType": "Folder", "tags": [
|
||||
{"name": "alarm_count", "tagType": "AtomicTag", "valueSource": "memory"}]}]}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- tests
|
||||
|
||||
class ExchangeLintTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.mkdtemp(prefix="exlint_test_")
|
||||
cls.clean = build_project(cls.tmp, "sample-resource", clean=True)
|
||||
cls.dirty = build_project(cls.tmp, "SampleResource_v2", clean=False)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
shutil.rmtree(cls.tmp, ignore_errors=True)
|
||||
|
||||
def run_lint(self, proj, **kw):
|
||||
kw.setdefault("tags", [])
|
||||
kw.setdefault("sql", [])
|
||||
kw.setdefault("resource", RESOURCE)
|
||||
rep, _name, _res = el.lint_project(proj, **kw)
|
||||
return rep
|
||||
|
||||
def rules(self, rep):
|
||||
return {f[1] for f in rep.findings}
|
||||
|
||||
# -- the clean project must be silent ---------------------------------
|
||||
|
||||
def test_clean_project_has_no_findings(self):
|
||||
rep = self.run_lint(self.clean)
|
||||
self.assertEqual(rep.findings, [],
|
||||
"conforming project produced findings:\n" +
|
||||
"\n".join("%s %s %s: %s" % f for f in rep.findings))
|
||||
|
||||
def test_clean_project_exits_zero(self):
|
||||
rc = el.main([self.clean, "--resource", RESOURCE, "--tags", "--sql"])
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
# -- the dirty project must fire each rule ----------------------------
|
||||
|
||||
def test_dirty_project_fires_expected_rules(self):
|
||||
got = self.rules(self.run_lint(self.dirty))
|
||||
for rule in ("E-PROJ-NAME", "E-PROJ-TITLE", "E-PROJ-DESC",
|
||||
"E-NS-ROOT", "E-COMP-NAME", "E-PROP-NAME",
|
||||
"E-PAGE-ROOT", "E-STYLE-NAME", "E-MSG-NAME",
|
||||
"E-STORE-SESS", "E-STORE-CTAG",
|
||||
"E-PY-TABS", "E-PY-FUNC", "E-PY-VAR", "E-PY-DOC",
|
||||
"E-PY-COMMENT", "E-UP-DOCS"):
|
||||
self.assertIn(rule, got, "expected %s to fire" % rule)
|
||||
|
||||
def test_dirty_project_exits_nonzero(self):
|
||||
rc = el.main([self.dirty, "--resource", RESOURCE, "--tags", "--sql"])
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
# -- severity model ---------------------------------------------------
|
||||
|
||||
def test_namespace_and_storage_are_failures(self):
|
||||
rep = self.run_lint(self.dirty)
|
||||
fails = {f[1] for f in rep.fails}
|
||||
for rule in ("E-NS-ROOT", "E-STORE-SESS", "E-STORE-CTAG", "E-PROJ-NAME"):
|
||||
self.assertIn(rule, fails, "%s should be a FAIL, not a warning" % rule)
|
||||
|
||||
def test_naming_rules_are_warnings_by_default(self):
|
||||
rep = self.run_lint(self.dirty)
|
||||
warns = {f[1] for f in rep.warns}
|
||||
for rule in ("E-COMP-NAME", "E-PY-FUNC", "E-PY-TABS"):
|
||||
self.assertIn(rule, warns, "%s should warn by default" % rule)
|
||||
|
||||
def test_strict_promotes_warnings(self):
|
||||
rep = self.run_lint(self.dirty, strict=True)
|
||||
self.assertEqual(rep.warns, [], "--strict left warnings behind")
|
||||
self.assertIn("E-COMP-NAME", {f[1] for f in rep.fails})
|
||||
|
||||
def test_ignore_suppresses_a_rule(self):
|
||||
rep = self.run_lint(self.dirty, ignore={"E-COMP-NAME"})
|
||||
self.assertNotIn("E-COMP-NAME", self.rules(rep))
|
||||
|
||||
# -- database and tag checks -----------------------------------------
|
||||
|
||||
def test_sql_clean_and_dirty(self):
|
||||
clean_sql = os.path.join(self.tmp, "clean.sql")
|
||||
dirty_sql = os.path.join(self.tmp, "dirty.sql")
|
||||
write(clean_sql, SQL_CLEAN)
|
||||
write(dirty_sql, SQL_DIRTY)
|
||||
|
||||
rep = el.Report()
|
||||
el.check_database(rep, [clean_sql], RESOURCE)
|
||||
self.assertEqual(rep.findings, [],
|
||||
"conforming schema produced findings: %s" % rep.findings)
|
||||
|
||||
rep = el.Report()
|
||||
el.check_database(rep, [dirty_sql], RESOURCE)
|
||||
got = {f[1] for f in rep.findings}
|
||||
for rule in ("E-DB-TABLE", "E-DB-COL", "E-DB-ID", "E-DB-FK"):
|
||||
self.assertIn(rule, got, "expected %s to fire" % rule)
|
||||
|
||||
def test_tags_clean_and_dirty(self):
|
||||
clean_tags = os.path.join(self.tmp, "clean-tags.json")
|
||||
dirty_tags = os.path.join(self.tmp, "dirty-tags.json")
|
||||
write_json(clean_tags, TAGS_CLEAN)
|
||||
write_json(dirty_tags, TAGS_DIRTY)
|
||||
|
||||
rep = el.Report()
|
||||
el.check_tags(rep, [clean_tags], RESOURCE)
|
||||
self.assertEqual(rep.findings, [],
|
||||
"conforming tag export produced findings: %s" % rep.findings)
|
||||
|
||||
rep = el.Report()
|
||||
el.check_tags(rep, [dirty_tags], RESOURCE)
|
||||
got = {f[1] for f in rep.findings}
|
||||
self.assertIn("E-NS-ROOT", got)
|
||||
self.assertIn("E-TAG-NAME", got)
|
||||
|
||||
# -- discovery --------------------------------------------------------
|
||||
|
||||
def test_discovers_both_projects_from_a_containing_folder(self):
|
||||
found = el.discover_projects(self.tmp)
|
||||
self.assertEqual(sorted(os.path.basename(p) for p in found),
|
||||
["SampleResource_v2", "sample-resource"])
|
||||
|
||||
def test_project_folder_resolves_to_itself(self):
|
||||
self.assertEqual(el.discover_projects(self.clean), [self.clean])
|
||||
|
||||
def test_zip_export_is_unpacked(self):
|
||||
archive = shutil.make_archive(
|
||||
os.path.join(self.tmp, "export"), "zip",
|
||||
root_dir=os.path.dirname(self.clean),
|
||||
base_dir=os.path.basename(self.clean))
|
||||
found = el.discover_projects(archive)
|
||||
self.assertEqual(len(found), 1)
|
||||
self.assertTrue(os.path.exists(os.path.join(found[0], "project.json")))
|
||||
|
||||
def test_resource_name_inferred_from_namespace(self):
|
||||
self.assertEqual(el.infer_resource(self.clean, "sample-resource"), RESOURCE)
|
||||
|
||||
# -- name predicates --------------------------------------------------
|
||||
|
||||
def test_name_predicates(self):
|
||||
self.assertTrue(el.is_pascal("AreaCard"))
|
||||
self.assertFalse(el.is_pascal("areaCard"))
|
||||
self.assertTrue(el.is_camel("startMs"))
|
||||
self.assertFalse(el.is_camel("start_ms"))
|
||||
self.assertTrue(el.is_kebab("bad-actors"))
|
||||
self.assertFalse(el.is_kebab("BadActors"))
|
||||
self.assertTrue(el.is_title("Alarm Dashboard"))
|
||||
self.assertTrue(el.is_title("Table of Contents"))
|
||||
self.assertFalse(el.is_title("alarm dashboard"))
|
||||
self.assertTrue(el.is_title_or_pascal("AlarmDashboard"))
|
||||
|
||||
def test_rule_table_ids_are_unique_and_referenced(self):
|
||||
ids = [r[0] for r in el.RULES]
|
||||
self.assertEqual(len(ids), len(set(ids)), "duplicate rule id in RULES")
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"exchange_lint.py")) as f:
|
||||
source = f.read()
|
||||
for rid in ids:
|
||||
self.assertGreaterEqual(source.count('"%s"' % rid), 2,
|
||||
"%s is declared but never raised" % rid)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
name: ignition-exchange-convert
|
||||
description: Convert an existing Ignition project into a publishable Exchange resource — re-namespacing views/styles/scripts, migrating Perspective session properties to view properties, renaming Python to the guide's conventions — and prove the conversion did not break anything. Use when asked to make a project Exchange-ready, de-brand a project for publication, or refactor one out of session-scoped state. Bundles verify_refs.py and parity.py, which catch the two failure modes a conformance checker cannot see: dangling references and silently dropped features. Not for merely checking conformance (use ignition-exchange-conformance) or uploading (use ignition-exchange-publish).
|
||||
---
|
||||
|
||||
# Converting a project into an Exchange resource
|
||||
|
||||
A conversion is a large mechanical rename plus one genuine refactor. It is
|
||||
**very easy to produce a project that is perfectly named and completely broken** —
|
||||
so the verification gates below are not optional garnish, they are the point.
|
||||
|
||||
Check the target shape first with
|
||||
[`ignition-exchange-conformance`](../ignition-exchange-conformance/SKILL.md);
|
||||
this skill is what to do about the findings.
|
||||
|
||||
## Work on a copy
|
||||
|
||||
Never convert in place. Copy the project to its new name, leave the original
|
||||
untouched, and diff against it at the end. The original is your parity baseline —
|
||||
without it you cannot prove you did not drop a feature.
|
||||
|
||||
Ignition project folder name **is** the project name, so the copy's directory
|
||||
must already be the new lowercase-dashed name.
|
||||
|
||||
## Order matters
|
||||
|
||||
Do it in this order. Step 2 invalidates bindings that step 3 would otherwise
|
||||
have to rewrite twice.
|
||||
|
||||
1. **Delete what must not ship.** Vision `client-tags/`, scratch views, dev-only
|
||||
resources.
|
||||
2. **Session properties → view properties**, while paths are still the familiar
|
||||
old ones. Verify the app still works before touching namespaces.
|
||||
3. **Re-namespace** views, style classes, scripts, page config — one mechanical
|
||||
sweep.
|
||||
4. **Python pass** — tabs, camelCase, docstrings, loggers.
|
||||
5. **Content sweep** — branding, internal names, placeholder text.
|
||||
6. **Verify** (gates below), then package.
|
||||
|
||||
## Step 2 is the real refactor
|
||||
|
||||
Everything else is search-and-replace. This one changes how the app works.
|
||||
|
||||
The guide forbids session custom properties in an Exchange resource because
|
||||
importing merges them into the host project's session props. Replace with:
|
||||
|
||||
- **State lives on the root view** as `custom` properties.
|
||||
- **Flows down** to child views as `params` (the guide is explicit: `params` =
|
||||
public/configuration, `custom` = internal).
|
||||
- **Flows up** as page-scoped messages handled on the root view, named
|
||||
`exchange.resourceName.handlerName`.
|
||||
|
||||
**Bindings must stay reactive.** Do not park binding-read state in
|
||||
`system.util.getGlobals()` — globals do not trigger Perspective binding updates.
|
||||
Globals are only for non-reactive cross-session data, and most resources need
|
||||
none.
|
||||
|
||||
Before rewriting a binding, check **what it actually depended on**. A bidirectional
|
||||
binding that writes a session prop does not necessarily re-trigger the data fetch;
|
||||
if the fetch keyed off a `refreshToken`, then an explicit Apply button was already
|
||||
the commit point and your message-based version preserves behaviour exactly. Read
|
||||
the dependency graph rather than assuming.
|
||||
|
||||
## Jython 2.7 constraints
|
||||
|
||||
Gateway-scoped code is Jython 2.7. Violations are silent until they reach the gateway.
|
||||
|
||||
- **No f-strings.** `%` formatting only.
|
||||
- No `typing`, `statistics`, `zoneinfo`.
|
||||
- Java exceptions bypass `except Exception:` — use bare `except:` with
|
||||
`sys.exc_info()` in gateway-facing defensive code.
|
||||
- **Scripts embedded in `view.json`** are JSON strings that must be tab-indented:
|
||||
the first character of every line in the block is a tab.
|
||||
|
||||
## Python renames
|
||||
|
||||
The guide wants camelCase functions and variables (deliberately not PEP-8) and
|
||||
tab indentation. Two traps:
|
||||
|
||||
- **Rename through the tokenizer, not regex.** A blind replace will corrupt
|
||||
string literals and attribute names that happen to match (`lt.tm_wday`).
|
||||
- **Do not rename data-dictionary keys.** Keys inside dicts returned to view
|
||||
bindings are a wire format between the scripts and every binding that reads
|
||||
them. They are not covered by any rule and renaming them is a large, risky
|
||||
diff for no conformance gain.
|
||||
|
||||
Tab conversion applies to **indentation** only — bracket continuation lines
|
||||
starting with spaces are fine and normal. Verify with the tokenizer's INDENT
|
||||
tokens, not by grepping for leading spaces.
|
||||
|
||||
## Verification gates
|
||||
|
||||
Run all four. Each catches something the others cannot.
|
||||
|
||||
### 1 · Conformance
|
||||
|
||||
```bash
|
||||
python3 skills/ignition-exchange-conformance/exchange_lint.py <new-project>
|
||||
```
|
||||
Target: **0 failures**. Warnings are a judgement call.
|
||||
|
||||
### 2 · Referential integrity
|
||||
|
||||
```bash
|
||||
python3 skills/ignition-exchange-convert/verify_refs.py <new-project> --old <OldNamespace>
|
||||
```
|
||||
|
||||
Resolves every embedded view path, style-class reference, `view.params.X` /
|
||||
`view.custom.X`, and every call into the project library against the functions
|
||||
that actually exist. Also flags leftover old-namespace strings, surviving
|
||||
`session.custom` reads, untabbed script transforms, f-strings, and `resource.json`
|
||||
drift. Target: **0 breakages**.
|
||||
|
||||
With ~40 function renames, a missed call site is the single most likely defect,
|
||||
and nothing else finds it.
|
||||
|
||||
### 3 · Feature parity
|
||||
|
||||
```bash
|
||||
python3 skills/ignition-exchange-convert/parity.py <original-project> <new-project>
|
||||
```
|
||||
|
||||
Fingerprints every view — component types and names, bindings by target property
|
||||
and type, script transforms, event and message handlers, embedded views, declared
|
||||
params — with names normalised for case, punctuation and snake/camel. **A pure
|
||||
rename cancels out; a dropped feature does not.**
|
||||
|
||||
Target: **zero `LOST` or `MISSING` lines.** `ADDED` lines are expected — the
|
||||
session-props refactor legitimately adds params and handlers. Read the diff, do
|
||||
not just count it.
|
||||
|
||||
### 4 · Behaviour
|
||||
|
||||
If the project has a test suite over its pure modules, run it against the
|
||||
converted library. Point the package name at the new location and alias
|
||||
camelCase functions **and their keyword arguments** back to the original names —
|
||||
a renamed kwarg (`higher_is_worse` → `higherIsWorse`) breaks callers that pass it
|
||||
by name, which no static check catches.
|
||||
|
||||
Compare against the **original project's** result, not against zero failures. A
|
||||
pre-existing failure staying pre-existing is a pass.
|
||||
|
||||
### Then: load it on a gateway
|
||||
|
||||
Static analysis cannot prove a screen renders. Load the project and open it.
|
||||
A clean project load proves resources parse; only a browser proves components
|
||||
render.
|
||||
|
||||
## De-branding
|
||||
|
||||
Conformance checkers read names, never label text. Sweep user-visible strings
|
||||
explicitly — `props.text`, `title`, `placeholder`, `tooltip` — for company names,
|
||||
project codenames, contest or demo references, internal hostnames, ticket numbers.
|
||||
Strip the text rather than deleting the component, so layout and parity are
|
||||
unchanged.
|
||||
|
||||
## Testing a minimum-version claim
|
||||
|
||||
To claim a floor lower than the gateway you built on, load the project on that
|
||||
version — **against a copy of the files**. An older gateway rewrites every
|
||||
`resource.json` with `lastModification` metadata and leaves root-owned
|
||||
`.resources/` artifacts behind that your user account cannot delete (clean up
|
||||
with a throwaway container rather than sudo).
|
||||
|
||||
A clean load proves resources parse. It does **not** prove screens render — a
|
||||
component property introduced in a later version fails at render time, not load
|
||||
time. Open the client.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report what the gates actually said, with numbers. A conversion reported
|
||||
accurately with two known gaps is worth far more than one claimed clean. If a
|
||||
gate is a false positive, say which and why — the tooling is not infallible and
|
||||
both bundled scripts have known blind spots documented in their docstrings.
|
||||
|
||||
## Provenance
|
||||
|
||||
Procedure and both scripts derived from a full conversion of a 20-view / 32-style
|
||||
/ 3-module Perspective project (2026-09-15), verified on Ignition 8.1.20 and
|
||||
8.3.7. The gate thresholds are the ones that actually caught defects during it.
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Functional-parity check between an original Ignition project and a converted copy.
|
||||
|
||||
Renaming is supposed to be behaviour-preserving. This compares what the two
|
||||
projects DO, ignoring what things are called: every view, every component, every
|
||||
binding, every event/message handler, every embedded view and style reference is
|
||||
fingerprinted with names normalised (case, punctuation, snake/camel folded) so a
|
||||
pure rename cancels out and a DROPPED FEATURE does not.
|
||||
|
||||
Usage: python3 parity.py <original_project> <converted_project>
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
PERSP = "com.inductiveautomation.perspective"
|
||||
EMBED = {"ia.display.view", "ia.display.flex-repeater"}
|
||||
DIFFS = []
|
||||
|
||||
|
||||
def diff(scope, msg):
|
||||
DIFFS.append((scope, msg))
|
||||
|
||||
|
||||
def norm(s):
|
||||
"""Fold a name to bare lowercase alphanumerics so renames cancel out."""
|
||||
return re.sub(r"[^a-z0-9]", "", str(s).lower())
|
||||
|
||||
|
||||
def segs(path):
|
||||
return [p for p in str(path).replace("\\", "/").split("/") if p]
|
||||
|
||||
|
||||
def common_prefix_len(paths):
|
||||
"""How many leading segments every resource shares -- i.e. the namespace.
|
||||
|
||||
Never eats the final segment, so a single-resource project still has a name.
|
||||
"""
|
||||
lists = [segs(p) for p in paths if segs(p)]
|
||||
if not lists:
|
||||
return 0
|
||||
n = 0
|
||||
while True:
|
||||
if any(len(l) <= n + 1 for l in lists):
|
||||
break
|
||||
first = lists[0][n]
|
||||
if any(l[n] != first for l in lists):
|
||||
break
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def key_of(path, strip):
|
||||
"""Namespace-stripped identity: folds case, punctuation and re-foldering."""
|
||||
return norm("".join(segs(path)[strip:]))
|
||||
|
||||
|
||||
def collect(base, payload):
|
||||
out = {}
|
||||
if not os.path.isdir(base):
|
||||
return out
|
||||
for dp, _d, fs in os.walk(base):
|
||||
if payload in fs:
|
||||
rel = os.path.relpath(dp, base).replace(os.sep, "/")
|
||||
if rel != ".":
|
||||
out[rel] = os.path.join(dp, payload)
|
||||
return out
|
||||
|
||||
|
||||
def walk(node, visit, path="root"):
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
visit(node, path)
|
||||
for i, c in enumerate(node.get("children") or []):
|
||||
n = (c.get("meta") or {}).get("name", "?") if isinstance(c, dict) else "?"
|
||||
walk(c, visit, "%s/%s[%d]" % (path, n, i))
|
||||
|
||||
|
||||
def view_fingerprint(vfile, embed_key):
|
||||
"""A behaviour fingerprint for one view, insensitive to naming."""
|
||||
view = json.load(open(vfile, encoding="utf-8"))
|
||||
fp = {
|
||||
"types": Counter(),
|
||||
"names": Counter(),
|
||||
"bindings": Counter(),
|
||||
"transforms": Counter(),
|
||||
"events": Counter(),
|
||||
"handlers": Counter(),
|
||||
"embeds": Counter(),
|
||||
"stylerefs": 0,
|
||||
"params": set(),
|
||||
"components": 0,
|
||||
}
|
||||
|
||||
def record_props(node, owner):
|
||||
for key, cfg in (node.get("propConfig") or {}).items():
|
||||
b = (cfg or {}).get("binding")
|
||||
if not b:
|
||||
continue
|
||||
# the bound property is the feature; its binding type is how it is fed
|
||||
fp["bindings"][(norm(key), b.get("type"))] += 1
|
||||
for tr in (b.get("transforms") or []):
|
||||
fp["transforms"][tr.get("type")] += 1
|
||||
scripts = node.get("scripts") or {}
|
||||
for ev in (scripts.get("customMethods") or []):
|
||||
fp["events"][("method", norm(ev.get("name")))] += 1
|
||||
for mh in (scripts.get("messageHandlers") or []):
|
||||
fp["handlers"][norm((mh.get("messageType") or "").split(".")[-1])] += 1
|
||||
for comp in (scripts.get("extensionFunctions") or {}):
|
||||
fp["events"][("ext", norm(comp))] += 1
|
||||
events = node.get("events") or {}
|
||||
for domain, acts in events.items():
|
||||
if isinstance(acts, dict):
|
||||
for act in acts:
|
||||
fp["events"][(norm(domain), norm(act))] += 1
|
||||
|
||||
record_props(view, "view")
|
||||
for k in (view.get("propConfig") or {}):
|
||||
if k.startswith("params."):
|
||||
fp["params"].add(norm(k.split(".")[1].split("[")[0]))
|
||||
for k in (view.get("params") or {}):
|
||||
fp["params"].add(norm(k))
|
||||
|
||||
def visit(node, cpath):
|
||||
if cpath != "root":
|
||||
fp["components"] += 1
|
||||
fp["types"][node.get("type")] += 1
|
||||
nm = (node.get("meta") or {}).get("name")
|
||||
if nm:
|
||||
fp["names"][norm(nm)] += 1
|
||||
record_props(node, cpath)
|
||||
if node.get("type") in EMBED:
|
||||
p = (node.get("props") or {}).get("path")
|
||||
if isinstance(p, str) and p:
|
||||
fp["embeds"][embed_key(p)] += 1
|
||||
cls = ((node.get("props") or {}).get("style") or {}).get("classes")
|
||||
if isinstance(cls, str):
|
||||
fp["stylerefs"] += len([c for c in cls.split() if c])
|
||||
walk(view.get("root") or {}, visit)
|
||||
return fp
|
||||
|
||||
|
||||
def compare_counter(scope, label, a, b):
|
||||
lost = a - b
|
||||
gained = b - a
|
||||
for k, n in sorted(lost.items(), key=lambda kv: str(kv[0])):
|
||||
diff(scope, "LOST %s %s x%d" % (label, k, n))
|
||||
for k, n in sorted(gained.items(), key=lambda kv: str(kv[0])):
|
||||
diff(scope, "ADDED %s %s x%d" % (label, k, n))
|
||||
|
||||
|
||||
def main():
|
||||
old_p, new_p = os.path.abspath(sys.argv[1]), os.path.abspath(sys.argv[2])
|
||||
|
||||
old_views = collect(os.path.join(old_p, PERSP, "views"), "view.json")
|
||||
new_views = collect(os.path.join(new_p, PERSP, "views"), "view.json")
|
||||
old_styles = collect(os.path.join(old_p, PERSP, "style-classes"), "style.json")
|
||||
new_styles = collect(os.path.join(new_p, PERSP, "style-classes"), "style.json")
|
||||
|
||||
# Strip each project's own namespace so a pure re-namespacing cancels out.
|
||||
ov_strip = common_prefix_len(old_views)
|
||||
nv_strip = common_prefix_len(new_views)
|
||||
os_strip = common_prefix_len(old_styles)
|
||||
ns_strip = common_prefix_len(new_styles)
|
||||
|
||||
def index(d, strip):
|
||||
out = {}
|
||||
for k, v in d.items():
|
||||
out.setdefault(key_of(k, strip), []).append((k, v))
|
||||
return out
|
||||
|
||||
oi, ni = index(old_views, ov_strip), index(new_views, nv_strip)
|
||||
for key in sorted(set(oi) - set(ni)):
|
||||
diff("views", "MISSING view '%s' (no counterpart for %s)"
|
||||
% (key, oi[key][0][0]))
|
||||
for key in sorted(set(ni) - set(oi)):
|
||||
diff("views", "EXTRA view '%s' (%s)" % (key, ni[key][0][0]))
|
||||
|
||||
# Style classes legitimately change shape (Grade/A -> grade-a, and
|
||||
# E-STYLE-HIER re-folders them), so identity matching would be noise.
|
||||
# What must not change is that none disappeared -- and verify_refs.py
|
||||
# separately proves every remaining reference still resolves.
|
||||
if len(old_styles) != len(new_styles):
|
||||
diff("styles", "style class count %d -> %d" % (len(old_styles), len(new_styles)))
|
||||
okeys = {key_of(k, os_strip) for k in old_styles}
|
||||
nkeys = {key_of(k, ns_strip) for k in new_styles}
|
||||
for k in sorted(okeys - nkeys):
|
||||
diff("styles", "no class resembling '%s' in the new project" % k)
|
||||
|
||||
# ---- per-view behaviour ---------------------------------------------
|
||||
for key in sorted(set(oi) & set(ni)):
|
||||
ofp = view_fingerprint(oi[key][0][1], lambda p: key_of(p, ov_strip))
|
||||
nfp = view_fingerprint(ni[key][0][1], lambda p: key_of(p, nv_strip))
|
||||
scope = "view %s" % key
|
||||
if ofp["components"] != nfp["components"]:
|
||||
diff(scope, "component count %d -> %d"
|
||||
% (ofp["components"], nfp["components"]))
|
||||
if ofp["stylerefs"] != nfp["stylerefs"]:
|
||||
diff(scope, "style class references %d -> %d"
|
||||
% (ofp["stylerefs"], nfp["stylerefs"]))
|
||||
for label in ("types", "names", "bindings", "transforms",
|
||||
"events", "handlers", "embeds"):
|
||||
compare_counter(scope, label[:-1] if label.endswith("s") else label,
|
||||
ofp[label], nfp[label])
|
||||
lost_params = ofp["params"] - nfp["params"]
|
||||
if lost_params:
|
||||
diff(scope, "params no longer declared: %s" % ", ".join(sorted(lost_params)))
|
||||
|
||||
# ---- library surface -------------------------------------------------
|
||||
def lib(root):
|
||||
out = {}
|
||||
base = os.path.join(root, "ignition", "script-python")
|
||||
for dp, _d, fs in os.walk(base):
|
||||
if "code.py" not in fs:
|
||||
continue
|
||||
mod = os.path.basename(dp)
|
||||
src = open(os.path.join(dp, "code.py"), encoding="utf-8",
|
||||
errors="replace").read()
|
||||
fns = {}
|
||||
for m in re.finditer(r"^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)",
|
||||
src, re.M):
|
||||
args = [a.strip().split("=")[0].strip()
|
||||
for a in m.group(2).split(",") if a.strip()]
|
||||
fns[norm(m.group(1))] = len(args)
|
||||
out[norm(mod)] = fns
|
||||
return out
|
||||
|
||||
ol, nl = lib(old_p), lib(new_p)
|
||||
for mod in sorted(set(ol) - set(nl)):
|
||||
diff("library", "MISSING module %s" % mod)
|
||||
for mod in sorted(set(ol) & set(nl)):
|
||||
for fn in sorted(set(ol[mod]) - set(nl[mod])):
|
||||
diff("library", "MISSING function %s.%s" % (mod, fn))
|
||||
for fn in sorted(set(ol[mod]) & set(nl[mod])):
|
||||
if ol[mod][fn] != nl[mod][fn]:
|
||||
diff("library", "arity change %s.%s: %d -> %d args"
|
||||
% (mod, fn, ol[mod][fn], nl[mod][fn]))
|
||||
|
||||
# ---- pages -----------------------------------------------------------
|
||||
def pages(root):
|
||||
p = os.path.join(root, PERSP, "page-config", "config.json")
|
||||
if not os.path.exists(p):
|
||||
return {}
|
||||
return (json.load(open(p)) or {}).get("pages") or {}
|
||||
op, np_ = pages(old_p), pages(new_p)
|
||||
if len(op) != len(np_):
|
||||
diff("pages", "page count %d -> %d" % (len(op), len(np_)))
|
||||
ot = {key_of(v.get("viewPath", ""), ov_strip) for v in op.values()}
|
||||
nt = {key_of(v.get("viewPath", ""), nv_strip) for v in np_.values()}
|
||||
for k in sorted(ot - nt):
|
||||
diff("pages", "no page maps to view %s any more" % k)
|
||||
|
||||
for scope, msg in DIFFS:
|
||||
print("DIFF %-28s %s" % (scope, msg))
|
||||
print("parity: %d view(s) vs %d, %d style(s) vs %d, %d difference(s)"
|
||||
% (len(old_views), len(new_views), len(old_styles), len(new_styles),
|
||||
len(DIFFS)))
|
||||
return 1 if DIFFS else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Referential-integrity check for a migrated Ignition project.
|
||||
|
||||
The style checker tests NAMES. This tests that everything still POINTS somewhere
|
||||
real after a namespace + function rename: embedded views, style classes, view
|
||||
params, and every call into the project library. A migration can be perfectly
|
||||
named and completely broken; this is the half that catches that.
|
||||
|
||||
Usage: python3 verify_refs.py <project_dir> [--old OldNamespace]
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
PERSP = "com.inductiveautomation.perspective"
|
||||
EMBED = {"ia.display.view", "ia.display.flex-repeater"}
|
||||
FAILS = []
|
||||
|
||||
|
||||
def fail(where, msg):
|
||||
FAILS.append((where, msg))
|
||||
|
||||
|
||||
def walk(node, visit, path="root"):
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
visit(node, path)
|
||||
for i, c in enumerate(node.get("children") or []):
|
||||
n = (c.get("meta") or {}).get("name", "?") if isinstance(c, dict) else "?"
|
||||
walk(c, visit, "%s/%s[%d]" % (path, n, i))
|
||||
|
||||
|
||||
def main():
|
||||
proj = os.path.abspath(sys.argv[1])
|
||||
# The project's previous namespace, e.g. "MyCompany". Any surviving mention
|
||||
# of it in the converted project is a missed rename.
|
||||
old = ""
|
||||
if "--old" in sys.argv:
|
||||
old = sys.argv[sys.argv.index("--old") + 1]
|
||||
|
||||
views_root = os.path.join(proj, PERSP, "views")
|
||||
styles_root = os.path.join(proj, PERSP, "style-classes")
|
||||
sp_root = os.path.join(proj, "ignition", "script-python")
|
||||
|
||||
def collect(base, payload):
|
||||
out = set()
|
||||
for dp, _d, fs in os.walk(base):
|
||||
if payload in fs:
|
||||
r = os.path.relpath(dp, base)
|
||||
if r != ".":
|
||||
out.add(r.replace(os.sep, "/"))
|
||||
return out
|
||||
|
||||
views = collect(views_root, "view.json")
|
||||
styles = collect(styles_root, "style.json")
|
||||
|
||||
# ---- project library: package path -> {function names} ----------------
|
||||
lib = {}
|
||||
for dp, _d, fs in os.walk(sp_root):
|
||||
if "code.py" not in fs:
|
||||
continue
|
||||
pkg = os.path.relpath(dp, sp_root).replace(os.sep, ".")
|
||||
src = open(os.path.join(dp, "code.py"), encoding="utf-8",
|
||||
errors="replace").read()
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
lib[pkg] = {n.name for n in tree.body
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
|
||||
except SyntaxError as e:
|
||||
fail(pkg + "/code.py", "does not parse: line %s %s" % (e.lineno, e.msg))
|
||||
lib[pkg] = set()
|
||||
if re.search(r"""(^|[^A-Za-z0-9_'"])[fF]["']""", src):
|
||||
fail(pkg + "/code.py", "f-string present (Jython 2.7 cannot parse)")
|
||||
|
||||
lib_roots = {p.split(".")[0] for p in lib}
|
||||
|
||||
# ---- per view ---------------------------------------------------------
|
||||
for vp in sorted(views):
|
||||
vfile = os.path.join(views_root, vp.replace("/", os.sep), "view.json")
|
||||
where = "views/" + vp
|
||||
raw = open(vfile, encoding="utf-8", errors="replace").read()
|
||||
try:
|
||||
view = json.loads(raw)
|
||||
except Exception as e:
|
||||
fail(where, "invalid JSON: %s" % e)
|
||||
continue
|
||||
|
||||
if old and old in raw:
|
||||
hits = len(re.findall(re.escape(old), raw))
|
||||
fail(where, "%d leftover reference(s) to old namespace %r" % (hits, old))
|
||||
if "session.custom" in raw:
|
||||
fail(where, "still reads session.custom (Exchange resources must not)")
|
||||
|
||||
declared = set((view.get("params") or {}).keys())
|
||||
declared_custom = set((view.get("custom") or {}).keys())
|
||||
for k in (view.get("propConfig") or {}):
|
||||
if k.startswith("params."):
|
||||
declared.add(k.split(".")[1].split("[")[0])
|
||||
elif k.startswith("custom."):
|
||||
declared_custom.add(k.split(".")[1].split("[")[0])
|
||||
|
||||
def visit(node, cpath):
|
||||
if node.get("type") in EMBED:
|
||||
p = (node.get("props") or {}).get("path")
|
||||
bound = "props.path" in (node.get("propConfig") or {})
|
||||
if isinstance(p, str) and p:
|
||||
if p not in views:
|
||||
fail(where, "%s embeds missing view %r" % (cpath, p))
|
||||
elif not bound:
|
||||
pass
|
||||
cls = ((node.get("props") or {}).get("style") or {}).get("classes")
|
||||
if isinstance(cls, str):
|
||||
for c in cls.split():
|
||||
if c and c not in styles:
|
||||
fail(where, "%s references missing style class %r" % (cpath, c))
|
||||
walk(view.get("root") or {}, visit)
|
||||
|
||||
# bindings: params/custom resolution, tab indent, library calls
|
||||
def each_cfg():
|
||||
for k, v in (view.get("propConfig") or {}).items():
|
||||
yield "view." + k, v
|
||||
acc = []
|
||||
walk(view.get("root") or {},
|
||||
lambda n, p: [acc.append(("%s %s" % (p, k), v))
|
||||
for k, v in (n.get("propConfig") or {}).items()])
|
||||
for it in acc:
|
||||
yield it
|
||||
|
||||
for owner, cfg in each_cfg():
|
||||
b = (cfg or {}).get("binding")
|
||||
if not b:
|
||||
continue
|
||||
blobs = [json.dumps(b.get("config") or {})]
|
||||
for tr in b.get("transforms") or []:
|
||||
code = tr.get("code", "")
|
||||
blobs.append(code)
|
||||
if tr.get("type") == "script" and code and not code.startswith(("\t", "\n")):
|
||||
fail(where, "%s: script transform not tab-indented" % owner)
|
||||
blob = " ".join(blobs)
|
||||
for m in re.finditer(r"view\.params\.([A-Za-z0-9_]+)", blob):
|
||||
if m.group(1) not in declared:
|
||||
fail(where, "%s: undeclared view param %r" % (owner, m.group(1)))
|
||||
for m in re.finditer(r"view\.custom\.([A-Za-z0-9_]+)", blob):
|
||||
if m.group(1) not in declared_custom:
|
||||
fail(where, "%s: undeclared view custom prop %r" % (owner, m.group(1)))
|
||||
|
||||
check_lib_calls(raw, where, lib, lib_roots)
|
||||
|
||||
# ---- library calling itself ------------------------------------------
|
||||
for dp, _d, fs in os.walk(sp_root):
|
||||
if "code.py" not in fs:
|
||||
continue
|
||||
p = os.path.join(dp, "code.py")
|
||||
rel = os.path.relpath(p, proj)
|
||||
src = open(p, encoding="utf-8", errors="replace").read()
|
||||
if old and old in src:
|
||||
fail(rel, "leftover reference to old namespace %r" % old)
|
||||
check_lib_calls(src, rel, lib, lib_roots)
|
||||
|
||||
# ---- resource.json integrity -----------------------------------------
|
||||
for dp, ds, fs in os.walk(proj):
|
||||
if ".resources" in ds:
|
||||
fail(os.path.relpath(dp, proj), "authored .resources directory")
|
||||
ds.remove(".resources")
|
||||
if "resource.json" not in fs:
|
||||
continue
|
||||
rel = os.path.relpath(dp, proj)
|
||||
try:
|
||||
res = json.load(open(os.path.join(dp, "resource.json")))
|
||||
except Exception as e:
|
||||
fail(rel, "invalid resource.json: %s" % e)
|
||||
continue
|
||||
declared = res.get("files") or []
|
||||
payloads = [f for f in fs if f != "resource.json"]
|
||||
for f in declared:
|
||||
if f not in payloads:
|
||||
fail(rel, "resource.json declares missing file %r" % f)
|
||||
for f in payloads:
|
||||
if f not in declared:
|
||||
fail(rel, "undeclared payload file %r" % f)
|
||||
want = "A" if ("script-python" in rel or "global-props" in rel) else "G"
|
||||
if res.get("scope") != want:
|
||||
fail(rel, "scope %r, expected %r" % (res.get("scope"), want))
|
||||
|
||||
for where, msg in FAILS:
|
||||
print("BROKEN %s: %s" % (where, msg))
|
||||
print("refs: %d view(s), %d style class(es), %d library module(s), %d breakage(s)"
|
||||
% (len(views), len(styles), len(lib), len(FAILS)))
|
||||
return 1 if FAILS else 0
|
||||
|
||||
|
||||
def check_lib_calls(text, where, lib, lib_roots):
|
||||
"""Every <root>.<pkg>.<fn> reference must resolve to a real function."""
|
||||
for root in lib_roots:
|
||||
# (?<![.\w]) so a property path like session.custom.Foo.bar is not
|
||||
# mistaken for a call into a library package named Foo.
|
||||
for m in re.finditer(r"(?<![.\w])" + re.escape(root)
|
||||
+ r"((?:\.[A-Za-z_][A-Za-z0-9_]*)+)", text):
|
||||
parts = m.group(1).lstrip(".").split(".")
|
||||
for split in range(len(parts) - 1, 0, -1):
|
||||
pkg = ".".join([root] + parts[:split])
|
||||
fn = parts[split]
|
||||
if pkg in lib:
|
||||
if fn not in lib[pkg]:
|
||||
fail(where, "calls %s.%s which does not exist in %s/code.py"
|
||||
% (pkg, fn, pkg.replace(".", "/")))
|
||||
break
|
||||
# No prefix resolves to a library module, so this is not a library
|
||||
# reference at all. The same dotted namespace is used for message
|
||||
# types and popup ids (exchange.<resource>.<handler>), which are
|
||||
# strings, not code -- flagging those would be a false positive.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: ignition-exchange-publish
|
||||
description: Package and upload a finished Ignition resource to the Ignition Exchange — which file format to export, what to verify inside the archive before uploading, and what to put in every field of the Exchange upload form (title, tagline, categories, version, minimum Ignition platform, required modules, package files, custom installation instructions). Use when asked how to publish, export, or upload an Ignition project to the Exchange, or what format the Exchange expects. Not for checking conformance (use ignition-exchange-conformance) or converting a project into Exchange shape (use ignition-exchange-convert).
|
||||
---
|
||||
|
||||
# Publishing to the Ignition Exchange
|
||||
|
||||
## The artifact is a project export `.zip`
|
||||
|
||||
Produced by **Designer → File → Export**, or **Gateway → Config → Projects →
|
||||
*project* → Export**. Both give the same file.
|
||||
|
||||
> **Never upload a gateway backup (`.gwbk`).** It contains the entire gateway —
|
||||
> datasource credentials, journal profiles, tags, every other project. Wrong
|
||||
> artifact, and it leaks configuration into a public resource.
|
||||
|
||||
A repo that stores projects as unpacked files (the gateway-as-files pattern) has
|
||||
no zip in it — one has to be generated.
|
||||
|
||||
## What goes in the upload
|
||||
|
||||
The Exchange accepts multiple package files, 10 MB each.
|
||||
|
||||
| File | Upload as | When |
|
||||
|---|---|---|
|
||||
| `<resource>.zip` | Package file | Always — the project export |
|
||||
| `README.md` / PDF | Package file | Always, as a **separate** file |
|
||||
| Tag export `.json` | Package file | Only if it needs tags/UDTs |
|
||||
| Database dump `.sql` | Package file | Only if it needs a schema or seed data |
|
||||
| Screenshots | Images & Screenshots | Optional, strongly encouraged |
|
||||
|
||||
**The README is probably not inside the zip.** Ignition's export serialises
|
||||
recognised project resources — each is a directory with a `resource.json`. A
|
||||
loose `README.md` at the project root is not one, so the export is expected to
|
||||
drop it. Unzip and check rather than assuming. The guide asks for docs as a
|
||||
separate package file regardless:
|
||||
|
||||
> **Note:** If you already have detailed documentation for your resource please
|
||||
> note it here and include the documentation file in the Package File upload.
|
||||
|
||||
Note that a conformance checker's documentation rule inspects the working
|
||||
directory, not the archive — a passing lint does not prove the zip carries docs.
|
||||
|
||||
## Before packaging
|
||||
|
||||
The guide names four pre-upload checks. Three are often N/A; the first never is.
|
||||
|
||||
1. **Check the project export** — import the zip into a brand-new project, *and*
|
||||
into a project that already has content. The second is the case the whole
|
||||
namespacing convention exists for and the only way to prove nothing collides.
|
||||
2. **Include the tags** — if it binds to tags or UDTs, export them and verify
|
||||
coverage.
|
||||
3. **Database backups** — if it expects tables or seed data, include the dump and
|
||||
name the vendor (MySQL / MSSQL / PostgreSQL) in both the description and the
|
||||
install instructions.
|
||||
4. **Take notes while testing** — they become the Custom Installation
|
||||
Instructions. Readers range from beginner to intermediate.
|
||||
|
||||
## Verify the archive
|
||||
|
||||
```bash
|
||||
unzip -l <resource>.zip
|
||||
unzip -o <resource>.zip -d /tmp/verify-export
|
||||
```
|
||||
|
||||
- File count matches the project on disk, minus anything the export legitimately drops
|
||||
- Every view, style class and script module present
|
||||
- `README.md` presence — confirm, do not assume
|
||||
- No `.resources/` (gateway-owned cache), `__pycache__`, editor backups
|
||||
- No credentials anywhere
|
||||
|
||||
## The upload form
|
||||
|
||||
### Overview
|
||||
|
||||
| Field | Guidance |
|
||||
|---|---|
|
||||
| Visibility | `Public` unless genuinely private |
|
||||
| Title | Short, Title Case, says what it does; usually matches project title |
|
||||
| Tagline | One sentence, must **not** repeat the title |
|
||||
| Description | What it does, how it's used, who benefits, relevant industries |
|
||||
| Resource Type | Closest dropdown match + skill level for install/use complexity |
|
||||
| Background Image | Optional card styling |
|
||||
| Category | **Up to 3**: Alarming, Analytics, Connectivity, Dashboard, Diagnostics, Edge Computing, Enterprise, HMI, IIOT/MQTT, MES, Maintenance, Mapping/GIS, Mobile, Monitoring, Reporting, SCADA, Scripting, Security, Simulation, Trending/Charting, Utility |
|
||||
| Contact the Developer | Optional; your address is never shown |
|
||||
| Tags | **Up to 10** search keywords |
|
||||
| Images & Screenshots | Optional, highly encouraged |
|
||||
|
||||
### Package
|
||||
|
||||
| Field | Guidance |
|
||||
|---|---|
|
||||
| Version | `1.0.0` first release; later auto-increment. **Never** put the version in the project name |
|
||||
| Ignition Platform | The **minimum** required. Guide: *"If unsure, select the Ignition platform version in which the package was developed with."* Do not claim an untested floor |
|
||||
| Release Tagline | Short summary of this version's changes; required even for the first |
|
||||
| Release Notes | Fuller explanation of what changed and its impact |
|
||||
| Required Modules | IA / Cirrus Link / Sepasoft modules needed |
|
||||
| Maker Edition | Tick if it runs on Maker; unavailable if you require unsupported modules |
|
||||
| Other Requirements | External prerequisites, one per line; blank if none |
|
||||
| Package Files | The zip plus docs / tags / SQL |
|
||||
| Custom Installation Instructions | Step-by-step; assume a beginner. If you shipped separate docs, say so here |
|
||||
|
||||
The Exchange **auto-generates a readme** from the Description and Custom
|
||||
Installation Instructions — write both properly.
|
||||
|
||||
## Verifying a minimum-version claim
|
||||
|
||||
Load the project on a throwaway gateway at the version you intend to declare,
|
||||
using a **copy** of the files — an older gateway rewrites `resource.json`
|
||||
metadata and leaves root-owned `.resources/` behind.
|
||||
|
||||
A clean load proves resources parse. It does **not** prove screens render: a
|
||||
component property added in a later version fails at render time. Open the
|
||||
client and look. If you cannot test the lower version, declare the version you
|
||||
developed on.
|
||||
|
||||
## After publishing
|
||||
|
||||
- Updates are **new package versions on the same resource**, not new resources.
|
||||
- **Never rename the namespace folders between versions** — that turns an upgrade
|
||||
into a duplicate set of resources in every user's project.
|
||||
- Re-run conformance before every release.
|
||||
- Non-English resources: the guide encourages shipping a language dropdown with
|
||||
English alongside.
|
||||
|
||||
## Provenance
|
||||
|
||||
Field list and quoted text from Exchange Resources Style Guide v1.1.0, §Before
|
||||
You Upload and §Uploading to the Exchange. Export-drops-README is reasoning from
|
||||
how Ignition serialises resources, not an observed export — verify it.
|
||||
1243
exchangeResources/exchange_lint.py
Executable file
1243
exchangeResources/exchange_lint.py
Executable file
File diff suppressed because it is too large
Load Diff
317
exchangeResources/test_exchange_lint.py
Normal file
317
exchangeResources/test_exchange_lint.py
Normal file
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-test for exchange_lint.py.
|
||||
|
||||
Builds two throwaway Ignition projects in a temp directory -- one that follows
|
||||
the Exchange Resources Style Guide and one that breaks a known rule per check --
|
||||
then asserts the checker stays silent on the first and fires the expected rule
|
||||
id on the second. This is what keeps the rules honest: a rule that cannot fail
|
||||
and a rule that always fires both show up here.
|
||||
|
||||
Run directly (python3 test_exchange_lint.py) or under pytest.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import exchange_lint as el # noqa: E402
|
||||
|
||||
RESOURCE = "SampleResource"
|
||||
PERSP = el.PERSPECTIVE
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ fixtures
|
||||
|
||||
def write(path, text):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def write_json(path, obj):
|
||||
write(path, json.dumps(obj, indent=2))
|
||||
|
||||
|
||||
RESOURCE_JSON = {"scope": "G", "version": 1, "restricted": False,
|
||||
"overridable": True, "files": ["view.json"], "attributes": {}}
|
||||
|
||||
CLEAN_CODE = '''"""Sample library module."""
|
||||
|
||||
|
||||
def formatCount(rawCount):
|
||||
"""Return rawCount rendered for display."""
|
||||
# pad small values so the column stays aligned
|
||||
displayValue = "%d" % rawCount
|
||||
return displayValue
|
||||
'''
|
||||
|
||||
DIRTY_CODE = '''def format_count(raw_count):
|
||||
#no space after hash
|
||||
display_value = "%d" % raw_count
|
||||
return display_value
|
||||
'''
|
||||
|
||||
|
||||
def build_project(root, name, clean=True):
|
||||
"""Create a minimal Perspective project; clean=False breaks one rule per check."""
|
||||
proj = os.path.join(root, name)
|
||||
ns_upper = "Exchange/%s" % RESOURCE if clean else "MyCompany"
|
||||
ns_lower = "exchange/sample-resource" if clean else "MyCompany"
|
||||
lib_pkg = "exchange/sampleresource" if clean else "MyCompany"
|
||||
|
||||
write_json(os.path.join(proj, "project.json"), {
|
||||
"title": "Sample Resource" if clean else "",
|
||||
"description": "A sample Exchange resource." if clean else "",
|
||||
"enabled": True, "inheritable": False, "parent": "",
|
||||
})
|
||||
|
||||
view_dir = os.path.join(proj, PERSP, "views", *ns_upper.split("/"), "Dashboard")
|
||||
write_json(os.path.join(view_dir, "resource.json"), RESOURCE_JSON)
|
||||
write_json(os.path.join(view_dir, "view.json"), {
|
||||
"params": {"startMs": 0} if clean else {"start_ms": 0},
|
||||
"custom": {},
|
||||
"root": {
|
||||
"type": "ia.container.flex",
|
||||
"meta": {"name": "root"},
|
||||
"props": {"style": {"classes": "%s/dashboard/card" % ns_lower
|
||||
if clean else "%s/Card" % ns_lower}},
|
||||
"children": [{
|
||||
"type": "ia.display.label",
|
||||
"meta": {"name": "TitleLabel" if clean else "titleLabel"},
|
||||
"custom": {"labelText": ""} if clean else {"label_text": ""},
|
||||
"props": {},
|
||||
"scripts": {"messageHandlers": [{
|
||||
"messageType": ("exchange.sampleResource.refresh"
|
||||
if clean else "refresh")}]},
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
style_dir = os.path.join(proj, PERSP, "style-classes", *ns_lower.split("/"),
|
||||
*(("dashboard", "card") if clean else ("Card",)))
|
||||
write_json(os.path.join(style_dir, "resource.json"),
|
||||
dict(RESOURCE_JSON, files=["style.json"]))
|
||||
write_json(os.path.join(style_dir, "style.json"), {"style": {}})
|
||||
|
||||
write_json(os.path.join(proj, PERSP, "page-config", "config.json"),
|
||||
{"pages": {"/sample-resource" if clean else "/": {
|
||||
"viewPath": "%s/Dashboard" % ns_upper}}, "sharedDocks": {}})
|
||||
|
||||
write_json(os.path.join(proj, PERSP, "session-props", "props.json"),
|
||||
{"custom": {} if clean else {"MyCompany": {"selectedTab": 0}},
|
||||
"props": {}})
|
||||
|
||||
code_dir = os.path.join(proj, "ignition", "script-python",
|
||||
*lib_pkg.split("/"), "fmt")
|
||||
write_json(os.path.join(code_dir, "resource.json"),
|
||||
dict(RESOURCE_JSON, scope="A", files=["code.py"]))
|
||||
write(os.path.join(code_dir, "code.py"), CLEAN_CODE if clean else DIRTY_CODE)
|
||||
|
||||
if clean:
|
||||
write(os.path.join(proj, "README.md"), "# Sample Resource\n\nInstall steps.\n")
|
||||
else:
|
||||
vis_ctags = os.path.join(proj, el.VISION, "client-tags")
|
||||
write_json(os.path.join(vis_ctags, "resource.json"),
|
||||
dict(RESOURCE_JSON, files=["data.bin"]))
|
||||
write(os.path.join(vis_ctags, "data.bin"), "x")
|
||||
return proj
|
||||
|
||||
|
||||
SQL_CLEAN = """
|
||||
CREATE TABLE ex_sr_contacts (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
contact_name VARCHAR(64),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE ex_sr_calls (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
ex_sr_contacts_id INT,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (ex_sr_contacts_id) REFERENCES ex_sr_contacts(id)
|
||||
);
|
||||
"""
|
||||
|
||||
SQL_DIRTY = """
|
||||
CREATE TABLE Contacts (
|
||||
contactId INT NOT NULL AUTO_INCREMENT,
|
||||
ContactName VARCHAR(64),
|
||||
PRIMARY KEY (contactId)
|
||||
);
|
||||
CREATE TABLE ex_sr_calls (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
contact INT,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (contact) REFERENCES Contacts(contactId)
|
||||
);
|
||||
"""
|
||||
|
||||
TAGS_CLEAN = {"tags": [{"name": "Exchange", "tagType": "Folder", "tags": [
|
||||
{"name": RESOURCE, "tagType": "Folder", "tags": [
|
||||
{"name": "AlarmCount", "tagType": "AtomicTag", "valueSource": "memory"}]}]}]}
|
||||
|
||||
TAGS_DIRTY = {"tags": [{"name": "my_company", "tagType": "Folder", "tags": [
|
||||
{"name": "alarm_count", "tagType": "AtomicTag", "valueSource": "memory"}]}]}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- tests
|
||||
|
||||
class ExchangeLintTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.mkdtemp(prefix="exlint_test_")
|
||||
cls.clean = build_project(cls.tmp, "sample-resource", clean=True)
|
||||
cls.dirty = build_project(cls.tmp, "SampleResource_v2", clean=False)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
shutil.rmtree(cls.tmp, ignore_errors=True)
|
||||
|
||||
def run_lint(self, proj, **kw):
|
||||
kw.setdefault("tags", [])
|
||||
kw.setdefault("sql", [])
|
||||
kw.setdefault("resource", RESOURCE)
|
||||
rep, _name, _res = el.lint_project(proj, **kw)
|
||||
return rep
|
||||
|
||||
def rules(self, rep):
|
||||
return {f[1] for f in rep.findings}
|
||||
|
||||
# -- the clean project must be silent ---------------------------------
|
||||
|
||||
def test_clean_project_has_no_findings(self):
|
||||
rep = self.run_lint(self.clean)
|
||||
self.assertEqual(rep.findings, [],
|
||||
"conforming project produced findings:\n" +
|
||||
"\n".join("%s %s %s: %s" % f for f in rep.findings))
|
||||
|
||||
def test_clean_project_exits_zero(self):
|
||||
rc = el.main([self.clean, "--resource", RESOURCE, "--tags", "--sql"])
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
# -- the dirty project must fire each rule ----------------------------
|
||||
|
||||
def test_dirty_project_fires_expected_rules(self):
|
||||
got = self.rules(self.run_lint(self.dirty))
|
||||
for rule in ("E-PROJ-NAME", "E-PROJ-TITLE", "E-PROJ-DESC",
|
||||
"E-NS-ROOT", "E-COMP-NAME", "E-PROP-NAME",
|
||||
"E-PAGE-ROOT", "E-STYLE-NAME", "E-MSG-NAME",
|
||||
"E-STORE-SESS", "E-STORE-CTAG",
|
||||
"E-PY-TABS", "E-PY-FUNC", "E-PY-VAR", "E-PY-DOC",
|
||||
"E-PY-COMMENT", "E-UP-DOCS"):
|
||||
self.assertIn(rule, got, "expected %s to fire" % rule)
|
||||
|
||||
def test_dirty_project_exits_nonzero(self):
|
||||
rc = el.main([self.dirty, "--resource", RESOURCE, "--tags", "--sql"])
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
# -- severity model ---------------------------------------------------
|
||||
|
||||
def test_namespace_and_storage_are_failures(self):
|
||||
rep = self.run_lint(self.dirty)
|
||||
fails = {f[1] for f in rep.fails}
|
||||
for rule in ("E-NS-ROOT", "E-STORE-SESS", "E-STORE-CTAG", "E-PROJ-NAME"):
|
||||
self.assertIn(rule, fails, "%s should be a FAIL, not a warning" % rule)
|
||||
|
||||
def test_naming_rules_are_warnings_by_default(self):
|
||||
rep = self.run_lint(self.dirty)
|
||||
warns = {f[1] for f in rep.warns}
|
||||
for rule in ("E-COMP-NAME", "E-PY-FUNC", "E-PY-TABS"):
|
||||
self.assertIn(rule, warns, "%s should warn by default" % rule)
|
||||
|
||||
def test_strict_promotes_warnings(self):
|
||||
rep = self.run_lint(self.dirty, strict=True)
|
||||
self.assertEqual(rep.warns, [], "--strict left warnings behind")
|
||||
self.assertIn("E-COMP-NAME", {f[1] for f in rep.fails})
|
||||
|
||||
def test_ignore_suppresses_a_rule(self):
|
||||
rep = self.run_lint(self.dirty, ignore={"E-COMP-NAME"})
|
||||
self.assertNotIn("E-COMP-NAME", self.rules(rep))
|
||||
|
||||
# -- database and tag checks -----------------------------------------
|
||||
|
||||
def test_sql_clean_and_dirty(self):
|
||||
clean_sql = os.path.join(self.tmp, "clean.sql")
|
||||
dirty_sql = os.path.join(self.tmp, "dirty.sql")
|
||||
write(clean_sql, SQL_CLEAN)
|
||||
write(dirty_sql, SQL_DIRTY)
|
||||
|
||||
rep = el.Report()
|
||||
el.check_database(rep, [clean_sql], RESOURCE)
|
||||
self.assertEqual(rep.findings, [],
|
||||
"conforming schema produced findings: %s" % rep.findings)
|
||||
|
||||
rep = el.Report()
|
||||
el.check_database(rep, [dirty_sql], RESOURCE)
|
||||
got = {f[1] for f in rep.findings}
|
||||
for rule in ("E-DB-TABLE", "E-DB-COL", "E-DB-ID", "E-DB-FK"):
|
||||
self.assertIn(rule, got, "expected %s to fire" % rule)
|
||||
|
||||
def test_tags_clean_and_dirty(self):
|
||||
clean_tags = os.path.join(self.tmp, "clean-tags.json")
|
||||
dirty_tags = os.path.join(self.tmp, "dirty-tags.json")
|
||||
write_json(clean_tags, TAGS_CLEAN)
|
||||
write_json(dirty_tags, TAGS_DIRTY)
|
||||
|
||||
rep = el.Report()
|
||||
el.check_tags(rep, [clean_tags], RESOURCE)
|
||||
self.assertEqual(rep.findings, [],
|
||||
"conforming tag export produced findings: %s" % rep.findings)
|
||||
|
||||
rep = el.Report()
|
||||
el.check_tags(rep, [dirty_tags], RESOURCE)
|
||||
got = {f[1] for f in rep.findings}
|
||||
self.assertIn("E-NS-ROOT", got)
|
||||
self.assertIn("E-TAG-NAME", got)
|
||||
|
||||
# -- discovery --------------------------------------------------------
|
||||
|
||||
def test_discovers_both_projects_from_a_containing_folder(self):
|
||||
found = el.discover_projects(self.tmp)
|
||||
self.assertEqual(sorted(os.path.basename(p) for p in found),
|
||||
["SampleResource_v2", "sample-resource"])
|
||||
|
||||
def test_project_folder_resolves_to_itself(self):
|
||||
self.assertEqual(el.discover_projects(self.clean), [self.clean])
|
||||
|
||||
def test_zip_export_is_unpacked(self):
|
||||
archive = shutil.make_archive(
|
||||
os.path.join(self.tmp, "export"), "zip",
|
||||
root_dir=os.path.dirname(self.clean),
|
||||
base_dir=os.path.basename(self.clean))
|
||||
found = el.discover_projects(archive)
|
||||
self.assertEqual(len(found), 1)
|
||||
self.assertTrue(os.path.exists(os.path.join(found[0], "project.json")))
|
||||
|
||||
def test_resource_name_inferred_from_namespace(self):
|
||||
self.assertEqual(el.infer_resource(self.clean, "sample-resource"), RESOURCE)
|
||||
|
||||
# -- name predicates --------------------------------------------------
|
||||
|
||||
def test_name_predicates(self):
|
||||
self.assertTrue(el.is_pascal("AreaCard"))
|
||||
self.assertFalse(el.is_pascal("areaCard"))
|
||||
self.assertTrue(el.is_camel("startMs"))
|
||||
self.assertFalse(el.is_camel("start_ms"))
|
||||
self.assertTrue(el.is_kebab("bad-actors"))
|
||||
self.assertFalse(el.is_kebab("BadActors"))
|
||||
self.assertTrue(el.is_title("Alarm Dashboard"))
|
||||
self.assertTrue(el.is_title("Table of Contents"))
|
||||
self.assertFalse(el.is_title("alarm dashboard"))
|
||||
self.assertTrue(el.is_title_or_pascal("AlarmDashboard"))
|
||||
|
||||
def test_rule_table_ids_are_unique_and_referenced(self):
|
||||
ids = [r[0] for r in el.RULES]
|
||||
self.assertEqual(len(ids), len(set(ids)), "duplicate rule id in RULES")
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"exchange_lint.py")) as f:
|
||||
source = f.read()
|
||||
for rid in ids:
|
||||
self.assertGreaterEqual(source.count('"%s"' % rid), 2,
|
||||
"%s is declared but never raised" % rid)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user