Audit ArcGIS Online web maps, and the apps that embed them, for viewers who cannot open them.
A team publishes a storm dashboard for the public. The dashboard is shared with everyone. The web map inside it is shared with the organization only, because the map was built first, for staff, and nobody shared it again. Every person in the building opens the dashboard and sees the map, because everyone in the building is signed in. The public opens the same link and sees the dashboard frame around a grey rectangle. No error is raised. The dashboard loads, the map widget does not, and nobody who could fix it ever sees the problem.
A sharing audit that reads each item on its own passes this. The map is consistent with its layers. The dashboard is consistent with itself. The fault is on the edge between them: the app reaches a wider audience than the map it embeds.
sightline checks the edges it can find, from the layer up to the app. For every layer in every web map, it answers one question:
Is every viewer of the map also a viewer of the layer?
With --edges, it asks the same question one level up:
Is every viewer of the app also a viewer of the web map it embeds?
With --wcag, it also runs six accessibility rules over the same web maps.
This is a run of main() against the synthetic portal that the self-test builds. The portal
object is a fake and the services are a local stub, so no portal and no login were involved.
The output path is shortened to demo.xlsx; everything else is as printed:
Connected to Test Org as admin
Scanning 3 web maps...
3/3 maps 0s elapsed ~0s left
Read 2 apps (0 unreadable)
6 layer refs, 2 distinct layer items
3 findings of 6 layer refs in 0s -> demo.xlsx
OK 3
BROKEN 2
DEAD 1
2 app edges, 1 not openable by every viewer
4 accessibility findings
The Edges sheet of that run held two rows, worst first:
1 High BROKEN Dashboard Public board -> Staff map app is shared public but the web map is shared org
- OK StoryMap Org story -> Visitor map every viewer of the app can load the web map
Esri's item pages show the relationship between an app and its map. Esri's ArcGIS blog post "Discover how your layers, scenes, maps and apps connect" describes "Items used" and "Used by" sections on item pages. Use them to see what depends on what. I found no sharing comparison between the two ends in them, but I could not open the post itself, so check the item page in your own organization.
The ArcGIS API for Python has an
itemgraph
module. It builds a directed graph of the items an item requires and the items that require
it. Its examples include a web map inside a StoryMap. Its documentation page does not describe
a sharing comparison. If you need the full graph, use it.
GitHub has community tools in the same area. A repository search on 2026-10-22 found these, and I read only their descriptions. I did not run them.
- Austin-AECEomnis/ArcGIS-Online-Sharing-Audit-Toolkit audits item sharing across an organization and applies sharing updates.
- valdesrosier/arcGIS-item-dependency-finder and phaakma/arcgis_item_graph trace and graph the dependencies between items.
- dymaptic/accessible-map-agent writes accessible text descriptions for web maps with an AI model. sightline does not write descriptions. It only tells you which maps have none.
JoeGuzi/ArcGIS-Online-Web-Application-Audit
(last pushed 2025-05-20) is the closest match. Its description says it audits the web
applications in an organization, finds the web map each one uses, compares the sharing of the
app and the map, and writes the result to Excel. It also writes a second sheet of web maps that
no application uses. It has no licence file. I read its description and the head of its README
and did not run it, so I cannot say how it decides. If you only need an app against map
comparison and the unused-map list, try it first. sightline differs in what it states: it
compares group sets by group id, it reports UNKNOWN where it cannot tell instead of a silent
OK, it also checks each layer against its map, it adds the accessibility rules, and it exits
3 when the run was incomplete. I did not test the other tool against any of these points.
sightline reads the live portal for its edges. The OSS plan for this tool named a different source for them, the dependency graph of whobreaks and the sharewatch snapshot. I chose the live read so that the answer is the sharing today, at the cost of one extra pass over the apps.
That list shows only what the search found. A search for the name sightline on 2026-10-29
returned other projects with that name (analytics, a game, a drone search tool). None audits
ArcGIS content.
For the accessibility rules, the Massachusetts GIS accessibility guidance for web maps gives guidance for people who build web maps. A browser tool such as axe checks a rendered page. sightline checks what is in the web map's JSON, before anything is drawn.
--self-test needs Python 3.9 or newer and nothing else. A live run needs arcgis, pandas
and openpyxl. ArcGIS Pro ships all three, so you can use its interpreter and install nothing:
& "C:\Program Files\ArcGIS\Pro\bin\Python\envs\arcgispro-py3\python.exe" sightline.py --self-testOtherwise:
pip install arcgis pandas openpyxl
python sightline.py --self-test--self-test runs the assertion suite with no portal and no credentials. It starts a small
web server on 127.0.0.1 and talks to that. If the last line reads 0 failed and no SKIP line
sits above it, the install is good. A SKIP line means pandas or openpyxl is missing, so the
Excel report was not tested. That run still exits 0: read the SKIP line, not only the exit
code. --self-check is the old name for the same flag and still works.
Before it prints a single PASS, the self-test checks its own harness with plain comparisons,
not with check(): a check() that always passes, a raises() that never fails, or a footer
that exits 0 after a failure stops the run with the message self-test harness defect.
python sightline.py --limit 10 # smoke-test first
python sightline.py # the real run
python sightline.py --deep # also collect feature counts and last-edit dates
python sightline.py --edges # also flag apps shared wider than their web map
python sightline.py --wcag # also run the accessibility rule pack
python sightline.py -o report.xlsx # choose the output path
python sightline.py -o report.xlsx --apply # replace report.xlsx if it exists| Flag | Default | What it does |
|---|---|---|
-o, --out |
~/Downloads/sightline_<time>.xlsx |
The report path. |
--limit N (--max) |
all | Cap the web maps scanned, and the apps scanned per item type. |
--deep |
off | Add FeatureCount and LastEditUTC to the AllLayers sheet. |
--edges |
off | Add the Edges sheet: apps shared wider than the web map they embed. |
--wcag |
off | Add the WCAG sheet: the accessibility rule pack. |
--apply |
off | Replace the output file when it already exists. |
--self-test (--self-check) |
off | Run the offline assertions and exit. |
Every flag must be spelled in full. The parser sets allow_abbrev=False, so --ap is refused
with exit code 2 instead of being read as --apply, and --self-ch is refused instead of
being read as --self-check.
sightline is read-only toward your organization. The one thing it writes is the report. A new
report path needs no flag. An existing file is never replaced without --apply. These checks
run before the audit starts, so a refusal cannot cost you a finished run: -o is not empty,
-o does not name a folder (an existing folder, or a name that ends in a slash), its folder
exists, and the file does not exist unless you gave --apply. If a file appears at the path
during the audit, sightline still never replaces it without --apply. It writes
<name>_HHMMSS.xlsx beside it instead and prints that name.
Exit codes:
| Code | Meaning |
|---|---|
0 |
A complete run, or a green self-test (also one that printed SKIP). Findings do not change the code. |
1 |
A red self-test, a missing library, or any uncaught error such as a failed sign-in. |
2 |
A refused flag, or a refused -o: empty, a folder, a file that exists without --apply, or a folder that does not exist. |
3 |
The report was written, but the run is incomplete. The console prints INCOMPLETE and the reasons: no web maps were found; a web map or app could not be read, or came back empty; item ids could not be looked up; items have no group data; or the account lacks the portal:admin:viewItems privilege. |
A scheduled task can test for 3 to tell a run that could not audit everything from a clean
one.
Credentials resolve as a pair, and none of them is a file sightline writes. Username comes
from USERNAME in the config block, or ARCGIS_USER if that is blank; password from
PASSWORD, or ARCGIS_PASS. Then:
- Both found, it signs in with them.
- No username anywhere, it uses your signed-in ArcGIS Pro session, and prompts if there is none.
- Username but no password, it prompts for the password.
ARCGIS_URL overrides PORTAL_URL, because PORTAL_URL always holds a value.
Run it as an org administrator. A standard user only sees their own content, so the audit covers a fraction of the org and everything else reads as clean.
Scheduled tasks need a Python without ArcPy. sightline imports the arcgis package when
it signs in. That package probes for ArcPy when it loads. Under a non-interactive scheduled
task, the ArcGIS Pro licence may not be initialised. The import then fails with "The Product
License has not been initialized", before sightline reaches your portal. The ArcGIS Pro Python
has ArcPy, so it has this problem. For a scheduled run, use a Python that has arcgis
installed but not arcpy. Set ARCGIS_USER and ARCGIS_PASS for that run too. Without ArcPy
there is no Pro session to sign in with, and a task cannot answer a prompt.
If the task must use the ArcGIS Pro Python, block ArcPy before arcgis loads. An import hook
that raises ImportError for arcpy makes the package use an engine without ArcPy.
sightline does not install this hook itself. Save this wrapper next to sightline.py as
run_sightline.py, and point the task at the wrapper:
import builtins, os, runpy
os.environ.setdefault("ARCGIS_IO_ENGINE", "shapefile")
_real_import = builtins.__import__
def _no_arcpy(name, *args, **kwargs):
if name.split(".")[0] in ("arcpy", "arcgisscripting"):
raise ImportError("%s blocked for a scheduled run" % name)
return _real_import(name, *args, **kwargs)
builtins.__import__ = _no_arcpy
here = os.path.dirname(os.path.abspath(__file__))
runpy.run_path(os.path.join(here, "sightline.py"), run_name="__main__")ARCGIS_IO_ENGINE=shapefile stops the package from choosing ArcPy for file work. The hook
also removes Pro sign-in, so ARCGIS_USER and ARCGIS_PASS are still required.
Everything tunable sits in one CONFIG block at the top of the script. Command-line flags
override the matching value.
| Setting | Default | What it does |
|---|---|---|
PORTAL_URL |
https://www.arcgis.com |
Set to your portal URL for ArcGIS Enterprise |
USERNAME / PASSWORD |
blank | Leave blank, prefer env vars or the prompt |
MAX_MAPS |
10000 |
Ceiling on web maps fetched |
MAX_APPS |
10000 |
Ceiling on apps fetched, per item type (--edges) |
DEFAULT_LIMIT |
None |
Cap every run without passing --limit |
DEEP_DEFAULT |
False |
Always collect feature counts |
OUTPUT_DIR |
~/Downloads |
Where the report goes |
OUTPUT_PREFIX |
sightline |
Report filename is <prefix>_YYYYmmdd_HHMMSS.xlsx |
MAX_COL_WIDTH |
55 |
Excel column width cap |
WIDTH_SAMPLE_ROWS |
400 |
Rows sampled when sizing columns |
PROBE_TIMEOUT |
8 |
Seconds per service URL |
MAX_HOST_FAILS |
3 |
Failures before a host is skipped |
SECURED_CODES |
{401,403,498,499} |
REST codes meaning "needs a token" |
DEAD_CODES |
{400,404} |
REST codes meaning "not there" |
ITEM_CHUNK |
50 |
Item ids per batched lookup |
GROUP_CHUNK |
80 |
Item ids per content/itemsgroups call |
SEV |
public = High | Severity band per audience |
EDGE_TYPES |
four item types | Item types searched for --edges |
MAP_KEYS |
four JSON keys | Keys that may hold an embedded web map id |
PLACEHOLDER_TITLES |
six titles | Titles the map-title-placeholder rule flags |
Do not put a real password in PASSWORD. A password typed into a script is a password you
will eventually commit.
Three sheets, plus one for each of --edges and --wcag:
Findings is every layer reference that is not OK, sorted worst first. This is the work queue.
AllLayers is every layer reference examined, including healthy ones, for pivoting.
Summary carries run metadata, verdict totals, the worst maps, and the owners with the
most problems. That last table is who you email. With --edges it also counts edge verdicts.
With --wcag it counts findings per rule.
Edges (--edges) is every app-to-map edge, findings first. Severity follows the app's
audience, because the app is the link people open.
WCAG (--wcag) is one row per rule hit, with the rule, the WCAG criterion, the layer
and the reason. Severity follows the map's audience.
| Verdict | Meaning |
|---|---|
OK |
Every map viewer can load the layer |
BROKEN |
Some map viewers cannot load the layer |
DEAD |
The layer's service is gone, or its item was not found by the signed-in account. The item may be deleted, or the account may not be able to see it. The Reason column says which |
UNKNOWN |
Could not be determined. The Reason column says why |
Severity follows the map's audience, because that is who is affected:
1 High public, 2 Medium org, 3 Low shared, 4 Info private. Findings sorts by
severity then view count, so the most-visited broken public map is the first row.
A web map that cannot be read is one UNKNOWN row with Section <error>, so it appears in
Findings. The arcgis package's Item.get_data hides a failed read (a timeout, a 5xx, a 403)
and returns {}
(read in the installed arcgis 2.4.2 package, Item.get_data). So an
empty answer, a non-JSON answer, or JSON with none of operationalLayers, baseMap and
tables is treated as an unreadable map, never as a map with no layers. An app is judged the
same way: an empty or non-JSON answer is an unreadable app.
The Overexposed column tracks a separate problem: a public layer inside a non-public map.
Nothing is broken, but the data is shared more widely than its map implies.
Sharing is audience containment, not a severity rank. The common shortcut
(private < org = shared < public) hides real breakage, because org and shared are
incomparable audiences. An org member outside the group cannot load a group-shared layer,
so an org-wide map with a group-shared layer is broken for most of its audience.
| Layer | Map | Result |
|---|---|---|
public |
anything | OK |
org |
private |
OK |
shared |
private, same owner |
OK |
shared |
private, different owner |
UNKNOWN: the map owner's group membership is not read |
private |
private, same owner |
OK |
private |
private, different owner |
BROKEN |
org |
public |
BROKEN |
org |
org |
OK |
org |
shared |
UNKNOWN |
shared |
org or public |
BROKEN |
shared |
shared |
Compare group sets |
shared |
shared, groups unknown |
UNKNOWN |
private |
org, shared or public |
BROKEN |
An item shared to zero groups normalizes to private, since its real audience is the owner.
This holds for the map and for the layer. A layer shared to no group, owned by someone else,
is therefore BROKEN in a private map.
An org layer in a group-shared map is UNKNOWN, not OK. Esri lets a group take members from
other organizations. The Share items
page says: "Members of the group can be from the same organization or different organizations."
It also says that sharing with your organization means only members of your organization can
access the item, so a member of another organization cannot load an org-only layer. (I read
the page on 2026-10-29. It states the first sentence and the second one directly; the
conclusion about a group member from outside is mine.) sightline does not read group members,
so it cannot say that every member belongs to your organization.
Group sets are compared by group id, never by title. Two groups can have the same title, for example two "GIS Staff" groups made by different owners. A comparison by title would call them the same audience. Titles appear in the report only as labels.
A layer with no url, no itemId and no featureCollection used to read as UNKNOWN at the
map's severity, which put every public map with the OpenStreetMap base map in the work queue.
The Esri web map specification gives some layer types no url property at all:
OpenStreetMap,BingMapsAerial,BingMapsHybridandBingMapsRoadhave none (see the OpenStreetMap layer and Bing layer pages). sightline scores themOK: there is no item or service of the organization to share.WebTiledLayerkeeps its address intemplateUrl, andVectorTileLayerinstyleUrl(see the web tiled layer and vector tile layer pages). A template holds{level},{col}and{row}placeholders, so it cannot be probed. sightline scores theseUNKNOWNwith the reasontile layer addressed by a template or style url, and does not call themOK.- A group layer whose children are all embedded feature collections, or whose children are
group layers, is followed to those children. Only a layer with no address of any kind is
still
NO_TARGET.
Many layer references are not ArcGIS Online items at all. They are registered service URLs, on-premises server layers and basemaps. Tools that only compare item permissions mark all of them fine.
sightline fetches each distinct service URL anonymously, with no token, and reads what the public would get:
| Response | Meaning |
|---|---|
| Normal | The layer really is public |
401 403 498 499 |
Needs a token. In a public map this is BROKEN |
400 404 |
The service is gone (ArcGIS REST services only) |
A 200 reply whose error is not an object with a code |
UNKNOWN, reason error reply of unknown shape |
| Connection failure | UNKNOWN, with the reason recorded |
Only an ArcGIS REST service is probed: a layer whose layerType starts with ArcGIS, or
whose url holds /rest/services/, or a layer with no layerType at all. A WMS, KML, CSV or
other OGC layer reads UNKNOWN, reason not an ArcGIS REST service; not probed. The 400 rule
was measured on ArcGIS services only. A healthy OGC server answers 400 to a request that
lacks its own parameters, so the rule would call a working WMS layer DEAD.
This catches a map titled "Internal" that is shared publicly and points at token-secured services. No data leaks, but the map is broken for every visitor.
The probe sends the layer's own query string, because a layer built from a .../query?where=...
url needs it. It replaces any f= parameter with f=json. It drops every parameter whose name
contains token, key, pass, pwd, secret, sig, auth or credential, and it drops
any user:password@ part, so the probe sends no credential. The report shows the same cleaned
url in the LayerUrl column, in its hyperlink and in the WCAG sheet. The name test is a
heuristic. A secret in a parameter with another name is still copied.
TLS verification stays on. A certificate a browser would reject is a finding, not something to suppress.
--edges searches four item types, named as the Esri
Items and item types
page spells them: Dashboard, Web Mapping Application, StoryMap and Web Experience
(Experience Builder). It reads each app's JSON once.
The app is treated as the "map" in the table above, and the embedded web map is treated as the "layer". Read the table below as: the row is the web map's audience, the column is the app's.
| Web map is | App public |
App org |
App shared |
App private |
|---|---|---|---|---|
public |
OK | OK | OK | OK |
org |
BROKEN | OK | UNKNOWN | OK |
shared |
BROKEN | BROKEN | Compare group sets | OK if the owner is the same, else UNKNOWN |
private |
BROKEN | BROKEN | BROKEN | OK if the owner is the same |
The same rules apply as for layers. An item shared to zero groups is private, on either
side. Unknown group membership on either side is UNKNOWN, never a silent OK. An org web map
under a group-shared app is UNKNOWN, for the reason given in the layer table.
How an embedded map is found. Esri documents the data of these item types only in part.
The item-type page above lists a Text/JSON data file for Dashboard, Web Mapping Application
and Web Experience, and Text/JSON with File for StoryMap. It gives no key names for any of
them. Its one property for these types is the service url of a Web Mapping Application,
kept in the item's url. Esri's
configurable templates
page (the 10.5 version) describes a values object that holds settings such as a web map id. I found no official
shape for Dashboard, StoryMap or Experience Builder data. So sightline does not parse one shape
per type. It collects every 32-digit hex string held under the key itemId, webmap,
webmapId or mapId anywhere in the app's JSON. It then looks those ids up. It keeps an id
as an edge only when it resolves to an item of type Web Map. A layer id or a scene id is
counted in the Summary and dropped. An id that resolves to no item, or whose lookup failed,
becomes an UNKNOWN row at the app's severity: it may be the web map this app is shared
wider than, and a missing row would read as a clean result. A failed lookup also prints
INCOMPLETE and exits 3.
This has one consequence you must know. A key I did not list is a missed edge, not a wrong one. The edges sightline reports are real. The edges it misses are unknown.
The rules read the web map's JSON and its item summary. Nothing is drawn. Each rule names the WCAG 2.2 success criterion it serves. A clean run is not a conformance claim. These are the checks a web map's JSON can answer.
| Rule | WCAG | Level | Fires when |
|---|---|---|---|
map-title-placeholder |
2.4.2 Page Titled | A | The item title is empty, or one of PLACEHOLDER_TITLES such as "Untitled Map". |
map-no-summary |
1.1.1 Non-text Content | A | The item has no summary and no description. |
layer-no-title |
2.4.6 Headings and Labels | AA | A layer has an empty or blank title. |
layer-popup-off |
1.1.1 Non-text Content | A | A layer sets disablePopup to true. |
renderer-color-only |
1.4.1 Use of Color | A | A uniqueValue renderer has two or more classes whose symbols share one shape, size and outline, and differ only by color. |
label-halo-contrast |
1.4.3 Contrast (Minimum) | AA | A drawn label has a halo, and its text and halo are under 4.5 to 1. Large text needs 3 to 1. |
What each rule rests on, checked against the sources:
- WCAG 2.2 defines 1.4.1, 1.4.3, 1.1.1, 2.4.2 and 2.4.6 as sightline uses them. The contrast
ratio is
(L1 + 0.05) / (L2 + 0.05), from relative luminance with the 0.04045 sRGB threshold. The W3C Contrast (Minimum) page gives 4.5 to 1 for normal text, 3 to 1 for large text, and defines large as at least 18 point, or 14 point bold. - The Esri web map specification says a layer
titleis "a user-friendly string title for the layer that can be used in a table of contents", thatdisablePopupindicates "whether to allow a client to ignore the popups", that labels display whenshowLabelsistrue, and thatuniqueValueGroupsshould be used instead ofuniqueValueInfoswhen present. sightline readsuniqueValueGroupsfirst for that reason. Colors are four-element arrays of red, green, blue and alpha, 0 to 255. Labelfont.sizeis in points. See the layer, feature layer, unique value renderer and text symbol pages. - The item
snippet(summary) anddescriptionare item parameters in the REST common parameters page.
What the rules choose not to do:
- The base map is Esri's content, so its layers are never judged.
- A color that is not fully opaque is skipped. It is drawn over a base map that sightline cannot see, so its contrast has no answer.
classBreaksrenderers are skipped. They show an ordered value as a ramp, which is a different design question from telling nominal classes apart.- Picture and CIM symbols are skipped. They differ by more than color.
- A layer with no
popupInfois not flagged. The specification lets a client use the popup that the service defines, so a missingpopupInfodoes not mean popups are off. layer-popup-offis advisory. A reference layer may have good reason to hide its popup.
The naive version of this audit took about 19 minutes against an organization of a few hundred web maps. This one takes under three. I measured the numbers below against a live organization while building the tool. The self-test cannot reproduce them, because it has no portal. Three things account for the difference:
Never touch an unknown attribute on an Item. Item.__getattr__ performs a network
round trip before raising AttributeError. One hasattr() call costs 0.665 seconds. The
tool sightline replaced called it twice per layer to fill two columns that were empty in
every row, spending roughly 95% of its runtime in dead code.
Resolve items in batches. One id:(a OR b OR ...) search resolves 50 ids per round trip
instead of one, measured 18.8 times faster with byte-identical results.
Do not reach for threads. On a shared GIS session, 4 workers gave 1.3x, 8 gave 1.1x
and 16 gave 0.7x, slower than sequential. The connection serialises, so sightline stays
sequential and readable.
The self-test pins the rule and the batch size, not the times. Its fake Item raises an
AssertionError on any attribute that is not id, get_data or a dict key, so a full run
fails if the code ever probes one. A chunk test checks that ids go out 50 at a time.
--wcag adds no request. It reads the JSON that the layer audit already fetched. --edges
adds one search per item type, one read per app, the batched item lookups for the candidate
ids, and content/itemsgroups calls for the app ids and the web maps they embed.
$ python sightline.py --self-test
sightline self-test: 127.0.0.1 stub and temporary files only, no portal, no login
--------------------------------------------------------------------
PASS check() and raises() really do record a failure <-- pinned defect
...
PASS a footer that exits 0 on a failure, or hides the count and the last line, is caught <-- pinned defect
...
PASS an org-only layer in a group-shared map is unknown, since a group can hold outside members <-- pinned defect
...
PASS a WMS or KML layer is unknown, never dead or public; a REST url or ArcGIS type is probed <-- pinned defect
...
PASS a secured service never shows LayerAccess=public in AllLayers <-- pinned defect
...
PASS a map with OSM, Bing, tile-template, style-url, embedded-group and nested-group layers has no false NO_TARGET finding <-- pinned defect
...
PASS a token already in the layer url is not sent <-- pinned defect
...
PASS a web map whose read came back empty, non-JSON or without map keys is an error row, not a clean map <-- pinned defect
...
PASS a failed lookup chunk is unresolved, never reported as deleted <-- pinned defect
...
PASS a group app on an org map is unknown: a group can hold members from another organisation <-- pinned defect
...
PASS an app whose read came back empty or non-JSON is an error row, not an app that embeds nothing <-- pinned defect
...
PASS an embedded id with no item is an UNKNOWN row too, so a public app on a deleted map is not clean <-- pinned defect
...
PASS accessibility rows sort by audience, busiest map, map title, rule and layer <-- pinned defect
...
PASS a prefix of the write flag, --ap, is refused <-- pinned defect
...
PASS an existing folder, or a name that ends in a slash, is refused with or without --apply <-- pinned defect
...
PASS an existing report is not replaced without --apply <-- pinned defect
...
PASS Findings lists the most-visited broken public map first, ties by title and layer, got [('Able', 'L1'), ('Able', 'L2'), ('Bravo', 'L1'), ('Bravo', 'L2'), ('Alpha', 'L1'), ('Alpha', 'L2'), ('Charlie', 'L1'), ('Charlie', 'L2')]
...
PASS a map that cannot be read: the report is written and the exit code is 3, not 0 <-- pinned defect
--------------------------------------------------------------------
376 assertions, 0 failed
The full run prints all 376 assertions, each on its own PASS line. Each ... line is where
this block is cut.
Lines marked <-- pinned defect are cases where a real defect was found, or could return, and
the assertion keeps it from coming back.
The count is the same on every host that has pandas and openpyxl, and the same on every
host that lacks them:
| Host | Python | Packages | Count |
|---|---|---|---|
| Windows, ArcGIS Pro conda environment | 3.13.7 | pandas 3.0.3, openpyxl 3.1.5, arcgis 2.4.2 |
376 |
Windows, plain C:\Python313 |
3.13.2 | pandas 3.0.3, openpyxl 3.1.5 (user site) | 376 |
| Linux, virtual environment | 3.12.3 | pandas 3.1.0, openpyxl | 376 |
| Windows, virtual environment | 3.13.2 | pandas 3.0.5, no openpyxl |
325 |
Windows, plain C:\Python313 run with -I |
3.13.2 | none visible | 325 |
Linux, system python3 |
3.12.3 | none | 325 |
No Python 3.9 interpreter was available, so Python 3.9 was not run. The self-test parses the
source with ast.parse(..., feature_version=(3, 9)), and the file has no f-string.
The self-test never imports arcgis or arcpy, so they change nothing. On a Python without
pandas or openpyxl, 51 Excel assertions do not run. The run prints SKIP report writer
where they would be, and a second SKIP line above the footer. The count drops to 325. The
run still ends 0 failed and exits 0, so read the SKIP line before you trust the footer.
Windows and Linux therefore agree: 376 with the two packages, 325 without. They differ only
by which packages a given interpreter can import. Plain C:\Python313 on the author's
machine finds pandas in its user site folder, so a default run there prints 376; -I (or
PYTHONNOUSERSITE=1) hides the user site and prints 325. The Linux production host's system
python3 has no pandas, and nothing was installed on it for this work. The 376 on Linux
ran in a separate virtual environment.
The PASS lines and the footer agree: a run prints one PASS line for each assertion.
coverage run --branch sightline.py --self-check reports 100 percent of 1827 statements and
410 branches. Two runs gave that figure: coverage.py 7.16.1 on Windows, with the ArcGIS Pro
Python 3.13.7, and coverage.py 7.17.0 on Linux, in the virtual environment with Python 3.12.3.
Both have pandas and openpyxl. In an environment with coverage.py but without openpyxl,
the Excel writer and the whole-run tests cannot run, and the figure falls to about 84 percent:
frame(), summarize(), save_report(), defuse(), scrub(), write_excel(), _t_report(),
and the part of main() that follows the report-library load are missed.
- Never run against a live portal in this work. The portal calls (
content.search,get_data) are covered by a fake object with the same shape. The realarcgis.gis.GISclass insists on HTTPS (anhttp://portal URL raised anSSLErroron the first request), so a stub for it needs a throwaway certificate, which I did not make. The login, the search and the item reads are therefore unproven against a real portal. The anonymous service probe and the group lookup ran against a real local web server. - App JSON is undocumented. See "How an embedded map is found". The fixtures are synthetic
and written from the rules above, not copied from a real app. A real Dashboard, StoryMap or
Experience Builder app may keep its map id under a key that is not in
MAP_KEYS. content/itemsgroupsis not in the REST reference. I found no page for it. The documented per-item operation iscontent/items/<id>/groups. The group lookup reads the reply shape that the earlier versions of sightline read. The stub proves the parser reads that shape, not that your portal sends it. Thearcgis2.4.2 sharing helper (gis/_impl/_content_manager/sharing/api.py) passes agroups=list of every group in the organization. If your portal reports only the groups the signed-in account can see, an item shared to a group the account cannot see reads as shared to no group, and is scored as owner-only. That needs a live check.- The 498 and 499 codes have Esri sources, but the table is measured. Esri's REST authentication operations page shows code 498 with the message "Invalid Token", and the geocode service output page shows 499 with "Token required". The set of codes sightline treats as "needs a token" (401, 403, 498, 499) and as "not there" (400, 404) was measured against live services when sightline was first built. The stub proves the table is applied, not that it is complete.
- The Esri blog address was not opened. The post under "What already exists" returned HTTP 403 to a scripted fetch, so its title and description come from a search result.
- Web maps and four app types. Web scenes, Hub sites and other item types are not scanned. An app that embeds a layer directly, with no web map, has no edge in this tool.
- Group membership is item-level. sightline compares which groups a map and layer are shared to. It does not enumerate group members, so it cannot tell you a group is empty.
- UNKNOWN is deliberate. An unreachable host may be a firewall rule rather than a defect.
Check the
Reasoncolumn before acting. - A secured service in a non-public map stays UNKNOWN. Whether your org members hold credentials for an on-premises service cannot be determined from ArcGIS Online.
- An embedded feature collection that is an empty object counts as no target. The earlier
versions did the same. It shows as
UNKNOWN. - The
--wcagdescription check strips tags with a pattern, not a parser. A description made only of entities such as counts as text. - The probe fetches any http or https url a web map holds. Redirects are followed, and
private-network addresses are fetched too. Python's
urlopenalso follows a redirect from anhttpurl to anftp://url, so a hostile layer url can make the machine that runs sightline open an FTP connection. A hostile web map can make that machine request an arbitrary url. Only a status is kept. Run it from a host whose network reach you accept. - The probe reads a whole response body. The timeout applies to each read, not to the whole reply. A service that streams forever, or sends a byte at a time, can hang the run or use all memory.
- The probe reads any JSON object with no
errorkey as PUBLIC. A layer with nolayerTypeand a url outside/rest/services/is still probed, and a non-ArcGIS service that answers JSON there reads PUBLIC. A layer with a type other thanArcGIS...is not probed (see "Layers that are not items"). - A string error code is UNKNOWN. A reply such as
{"error": {"code": "498"}}is not classed SECURED, because the code table holds integers only. - Malformed layer urls trip the host circuit breaker. Three urls with a space, a control
character or a non-ASCII character fail as transport errors. Later good urls on the same
host then read
UNREACHABLEinstead of being probed. - A layer
itemId,urlor title that is not a string stops the run. A number or an object there raises aTypeErroror anAttributeErrorbefore any report is written. - An upper-case layer
itemIdreads as DEAD. It is looked up as given, and the search returns lower-case ids, so a live layer shows as a false DEAD. Overexposedonly fires for org-owned layer items. A public service found by the probe, or a public item from another organization, is never marked, which is narrower than "a public layer inside a non-public map".- Edges can be noisy. Dashboards and other apps keep layer ids under
itemIdtoo, so every layer item the signed-in account cannot see becomes anUNKNOWNrow inEdges, at the app's severity. A web map that is private to someone else, or deleted, does the same. The Summary rowcandidate ids that were not web mapscounts them asno item N. - An app that holds its map id under a key outside
MAP_KEYShas no row inEdges. Absence from the sheet is not a clean result. - An app whose data comes back empty reads as unreadable.
get_datareturns{}for a failed read, and sightline cannot tell that from an item that has no data file, such as a Web Mapping Application that is only a registered url. Either becomes anUNKNOWNrow and anINCOMPLETErun. I did not test what a real portal returns for an item with no data. - After a failed item lookup the run exits
3. Each candidate whose lookup failed is anUNKNOWNrow inEdges, and the Summary counts it aslookup failed N. - Owner names compare as exact strings.
Aliceandaliceare two owners. - A private map owned by A, with a layer shared to groups by B, reads UNKNOWN. sightline does not read whether A is in those groups, so it does not call this OK.
- The edge reason for a public app on a group-shared map reads "shared shared". The words are awkward and the verdict is right.
- The report path check runs once, before the audit. If the file appears or is locked
during the audit, sightline does not replace it without
--apply. It writes the report under a new name instead,<name>_HHMMSS.xlsxand then<name>_HHMMSS_2.xlsx, and prints the name. That is a write without--apply, though a new file only. The default output name has one-second resolution. - The
-ocheck cannot tell whether the account may write the folder. A folder that exists but refuses the write fails after the audit with an error and loses the run. - Error text is copied into the report. The text of a failed request or read goes into
the
Reasoncolumn and the console as the exception gives it. An exception that echoes its request url could carry a token from a legacy token-in-query login.clean_url()cleans the layer url columns.redact()removes credential-named query values anduser:password@parts from the text of a failed group lookup, item lookup, map read or app read before it is printed or stored. It works on the parameter name, likeclean_url(), so a percent-encoded name or a value under another name is not removed. - An item title with a lone surrogate stops the Excel write. It is reachable only through
the REST API, and it raises
UnicodeEncodeErrorat the end of the run. - A title longer than 32767 characters is cut. openpyxl cuts it in the workbook without a message.
- Non-ASCII text can stop the console. Printing an organization name or an error with
characters outside the console code page, such as cp1252 in a redirected scheduled run,
raises
UnicodeEncodeError. - Hyperlink cells keep the url scheme. A
javascript:,file:or UNC url from a hostile web map becomes a clickable link. A url over about 2079 characters may make Excel offer to repair the file. Do not click links in a report from an organization you do not trust. fetch_groups()expects the reply shape it knows. A group block that is not an object raises anAttributeError. This needs a malformed reply.connect()ignoresARCGIS_PASSandARCGIS_URLwhen a Pro session is found. That happens when no username is set, so the run can audit a different portal than the one configured. Only theConnected toline shows which.--deepcounts only layers on the portal host or anarcgis.comhost. Every other layer keeps a blankFeatureCountand gets the textskipped: not a portal or arcgis.com hostinLastEditUTC, so an Enterprise site with a separate server host gets no counts.- A run in which every service probe fails still exits
0. With the network or a proxy down, the url-only layers readUNKNOWNwith a reason such asRemoteDisconnectedorhost circuit-open, and nothing marks the runINCOMPLETE. - A token inside a layer url is dropped before the probe. A token-secured service whose map url carries a valid token reads SECURED, and BROKEN in a public map, although the embedded token does let anonymous viewers load it.
- A malformed item id goes into the search query unquoted. An id with a trailing newline
(the
$inID_REmatches before it), a quote or a parenthesis, in an app or a web map, changes theid:(a OR b ...)query for its whole chunk of 50 ids. The valid ids in that chunk can then readDEADorUNKNOWN. It needs a hostile or corrupt item. - An app can keep a web map id after the widget is removed. An unused Experience Builder data source is one example. sightline reports that id as an edge, so the edges it reports are real only for ids that are still embedded.
- A bare run with no credentials and no Pro session was seen to hang past 30 seconds
once, with no report and no traceback. The cause was not isolated. It may be the
GIS("pro")attempt. save_reportwith--applytruncates the old report before it writes. A full disk during the write loses the previous report.- The renderer and label rules read only what the web map stores.
renderer-color-onlyandlabel-halo-contrastlook atlayerDefinition.drawingInfo, so a layer drawn with its service's default style or labels is never judged. A clean WCAG sheet says nothing about it. renderer-color-onlyis advisory. It fires on any unique-value layer whose classes differ only by fill color. That includes ordinary categorical maps that also have a text legend. Treat every hit likelayer-popup-off.- The WCAG criterion numbers are the author's judgement. Success criteria 1.1.1 for
map-no-summaryand 2.4.2 formap-title-placeholderare written for page content, not for ArcGIS item metadata. - The Esri Share dialog was not checked. I could not confirm whether it warns about related items, so "I found no sharing comparison" covers the item pages and the documents I read, not Esri's whole interface.
- Some behaviour is not pinned by any assertion. A mutation sweep left these alive:
changing
if errors or app_errors:inincomplete_reasons()toand(the unit check only passes both lists together); the criterion number of each rule inWCAG_RULES; thewebmapIdandmapIdentries ofMAP_KEYS; the placeholder titlesnew map,mapandweb map; the order of the Top owners rows; theFeatureCountandLastEditUTCcolumn order;len(apps) + len(app_errors)in the app counts when no app is unreadable; and making thetemplateUrl,styleUrlorBUILTIN_TYPEStests in_has_target()conjunctive. - The contrast boundaries are not pinned. A ratio of exactly 4.5 or 3.0 (
ratio < need) and the sRGB 0.04045 threshold have no fixture, because no 8-bit gray gives an exact ratio. clean_url()misses some credential names.jwt,session,sessionidandbearerare not in the list, so a value under one of them stays in the report and is sent by the probe.USERNAMEandPASSWORDin the config block win over the environment. The safe path, an environment variable or the prompt, is a convention. Nothing enforces it.- The Mass.gov link was not opened by the author. The page returned HTTP 403 to a scripted fetch on 2026-10-22 and again on 2026-10-29. Check that it resolves.
- The "Product License has not been initialized" failure is reported, not reproduced. It
comes from scheduled-task experience. The self-test does not trigger it. The wrapper above
ran in the self-test environment, and
arcgis.gisimported witharcpyblocked. - Coverage on a bare interpreter is 84 percent. Without pandas and openpyxl the Excel writer and the whole-run tests cannot execute, so about 300 statements are missed. With both libraries it is 100 percent, so every line is reachable offline.
- A run that hits a ceiling stops early and still exits
0.MAX_MAPSandMAX_APPSare 10000, which is also the hard limit of thearcgissearch (it raises above 10000).--limitstops early by design. In both cases the Summary shows onlymaps scanned, with no note that the audit was cut short. - A layer url with an unmatched bracket in the host stops the run. A url such as
http://[::1makesprobe()raiseValueErrorat theurlparsecall.host()guards the same call, butprobe()does not, so no report is written. - When the group lookup for a web map fails, a private layer in it is BROKEN. The map
might really be shared to no group, so the layer would be fine. The run still prints
INCOMPLETEand exits3. renderer-color-onlycompares sizes withrepr(). Marker sizes 8 and 8.0 read as different shapes, so the rule stays silent. Fills that differ only in alpha count as "color only".- Only the font weight
boldlifts a label to the 14 point large-text threshold. A weight such asbolderis judged as normal text and needs 4.5 to 1. - A layer item that resolves wins over its service.
build()ignores the service probe when the layer'sitemIdresolves, so an item marked public is trusted even if the layer's ownurlis secured. This needs a mismatched item and url. clean_url()removes credentials by parameter name only. A secret under a name outside the list, a percent-encoded name, a;-separated parameter, or a token inside the path stays in the report and is sent by the probe.--deepsends the session token toarcgis.comhosts. For an ArcGIS Online portal it sends it to any https host underarcgis.comnamed by aFeatureServerlayer url in the map data. Therequestspackage re-sends theX-Esri-Authorizationheader on a redirect to another host, so a redirecting trusted host could forward the token.- Before Python 3.11.4,
urlparsekeeps leading whitespace. A layer url with a leading space reads "only http and https urls are probed" there, and is probed on newer Pythons.
Bug reports and feature requests go in GitHub issues.
Run python sightline.py --self-test before opening a pull request. The suite asserts the
full classification matrix, including the rule that org and shared are incomparable, so
reverting to a rank comparison fails loudly instead of quietly under-reporting.
Built by Asir Khan.
MIT. See LICENSE.
Other single-file tools in this portfolio that pair with this one:
- sharewatch - the same platform from the other side: what CHANGED last night, rather than what a viewer sees today
- ghostsvc - the anonymous services a viewer-side audit never reaches
- whobreaks - what breaks if you delete one of the items it flags
- deadwidget - the Experience Builder side: widgets bound to a layer or sublayer that no longer exists