One ArcGIS Pro toolbox in every project: the .pyt + Favorites pattern
Most GIS shops accumulate the same dozen scripts — an exporter, a thumbnail
stamper, a service inventory — and then copy them, project by project, until five slightly
different versions are drifting in five project folders. The fix isn’t more discipline. It’s
one Python toolbox, pinned once through ArcGIS Pro Favorites, so it loads in every project —
and a git pull is the entire update story.
This is the exact pattern we run at All Things Spatial. One toolbox, ATS-Tools.pyt,
lives in a git repo. Every workstation pins it as a favorite once. After that, adding or fixing a tool is a
commit on one machine and a pull on the next — the toolbox is already wired into Pro, so the new tool
simply appears. Here is how it’s built and why each decision earns its place.
What we actually ship
A single .pyt with a handful of focused tools, each tagged into a fixed set of
categories so the toolbox never sprawls:
| Tool | Category | What it does |
|---|---|---|
| Generate Branded AGOL Thumbnails | AGOL | Stamp a consistent branded thumbnail on every item you own (whole account or selected folders). |
| Describe Feature Services | AGOL | Inventory services — fields, types, domains, counts, spatial reference — to TXT / CSV / XLSX. |
| Export Pro Symbols to SVG/PNG | Symbology | Extract symbols from a .stylx to SVG (and PNG) for web/CDN reuse. |
| Clone Items Cross-Org | Migration | Assess-then-migrate content between ArcGIS Online orgs with a per-item decision report. |
| Export Features to Folder | Data Prep | Export a layer (optionally filtered) to Shapefile / CSV / GeoJSON / file geodatabase. |
| _Template — Copy Me | Templates | A skeleton tool with one of every common parameter type — clone it to start a new tool. |
The categories are a controlled vocabulary — AGOL, Data Prep, Symbology, Analysis,
Publishing, Migration, Templates — recorded in a CONVENTIONS.md in the repo.
A new category is a deliberate decision, not whatever string someone typed that day. That one rule is what
keeps a shared toolbox legible a year in.
Why a Python toolbox, not loose scripts or a model
A Python toolbox is just a .pyt file — plain text that defines a toolbox
and one or more tools, and behaves exactly like any system geoprocessing tool inside Pro, ModelBuilder, and
the Python window
(What is a Python toolbox — Esri).
Because it’s plain text, it is git-diffable: every change shows up as a clean line diff
in review. We reserve binary .atbx toolboxes for workflows that genuinely need to
stay visual in ModelBuilder; mature, code-shaped logic lives in the .pyt where it
can be read, reviewed, and edited in seconds.
The structure: thin tools over a testable lib
Each tool class stays thin — it declares parameters and calls a function. The real work lives in a
sibling lib/ package of pure-Python modules (ats_thumbnails.py,
ats_describe.py, …) that import cleanly without arcpy. That split
is the difference between logic you can unit-test on any machine and logic you can only run inside Pro.
import arcpy, os, sys class Toolbox(object): def __init__(self): self.label = "ATS Tools" self.alias = "atsTools" self.tools = [ExportFeatures, GenerateBrandedThumbnails, DescribeFeatureServices] class ExportFeatures(object): def __init__(self): self.label = "Export Features to Folder" self.description = "Export a layer to Shapefile / CSV / GeoJSON / FGDB." self.category = "Data Prep" # controlled vocabulary only self.canRunInBackground = False def getParameterInfo(self): layer = arcpy.Parameter(displayName="Input layer", name="layer", datatype="GPFeatureLayer", parameterType="Required", direction="Input") out = arcpy.Parameter(displayName="Output folder", name="out_folder", datatype="DEFolder", parameterType="Required", direction="Input") return [layer, out] def isLicensed(self): return True def updateParameters(self, p): return def updateMessages(self, p): return def execute(self, parameters, messages): export = _import_lib("ats_export") # lazy import + reload (below) export.run(parameters[0].valueAsText, parameters[1].valueAsText) messages.addMessage("Done.")
The Favorites pattern
Here is the move that makes one toolbox show up in every project. ArcGIS Pro lets you mark a toolbox as a
favorite and add favorites to every new project automatically
(Manage favorites — Esri).
A favorite stores a path to the live file — so it always points at whatever your last
git pull left on disk. Set it once per machine:
- In the Catalog pane, open the Computer tab and browse to your repo’s
…\toolbox\ATS-Tools.pyt. - Right-click the
.pyt→ Add To Favorites. - Switch to the Favorites tab, right-click ATS-Tools → Add To New Projects (a pin appears).
- Done. Every new project now opens with the toolbox already loaded — no per-project importing.
And the update story, in full:
# machine A — add or fix a tool git commit -am "describe-services: add domain export" && git push # machine B — the favorite already points at this file git pull # next project you open has the new tool. No re-pin.
%AppData%\Esri\ArcGISPro\Favorites\Favorites.json — per Windows profile, and
not in your repo. So the pin is a one-time setup on each workstation. It also stores an absolute
path, so every machine must mount the repo at the same path (or point the favorite at a UNC
share like \\server\gis\ats-scripts). We keep a tiny stdlib-only
check_favorites.py in the repo that reads Favorites.json
and confirms the pin is wired — a 5-second verification on a new machine.The reload gotcha
One trap will cost you twenty minutes the first time. Catalog > Refresh reloads the
.pyt, but Pro caches the sibling lib/ modules — so
your code edit in ats_export.py appears to do nothing. The fix is to
importlib.reload() the lib module inside the import helper every tool uses:
def _import_lib(name): """Import a sibling lib/ module, reloading so edits land without a full Pro restart.""" libdir = os.path.join(os.path.dirname(__file__), "lib") if libdir not in sys.path: sys.path.insert(0, libdir) import importlib mod = importlib.import_module(name) return importlib.reload(mod) # pick up lib/ edits on the next run
Operator note: after you first add those reload calls, the first pickup still needs a Pro restart
(to load the .pyt that contains them). Every edit after that lands on a re-run.
Guardrails we bake in
A few small conventions keep a shared toolbox safe to hand to anyone:
Dry run defaults ON for any tool that writes to a portal. The user has to consciously turn it off before anything is mutated:
dry = arcpy.Parameter(displayName="Dry run (preview only — do not write to the portal)", name="dry_run", datatype="GPBoolean", parameterType="Optional", direction="Input") dry.value = True # default ON # … later, in execute() … if not dry_run: item.update(thumbnail=str(path)) # the only line that touches the portal
No credentials in code. Tools that talk to ArcGIS Online use the active Pro sign-in; cross-org tools use named keyring profiles entered once, never strings in a file:
from arcgis.gis import GIS gis = GIS("pro") # the signed-in Pro session — nothing secret on disk
Lazy imports. Heavy dependencies (PIL,
arcgis, openpyxl) are imported inside
execute(), not at the top of the .pyt. The toolbox
always loads fast, and a missing dependency in one environment never crashes the whole toolbox — only
the tool that needs it.
Why this is worth the setup
Your house tooling is a product surface. When every project on every machine has the same vetted tools a pull away — with dry-run guards, no secrets on disk, and a clean line diff on every change — you stop maintaining five copies of one script and start compounding one good toolbox. That’s the difference between scripts you happen to have and tooling you actually run.
References
- What is a Python toolbox — ArcGIS Pro documentation
- Define parameters in a Python toolbox — ArcGIS Pro
- Manage favorites — ArcGIS Pro
Want your team’s scripts to work like this?
We turn a folder of one-off ArcPy scripts into one shared, version-controlled Pro toolbox — thin tools over a tested library, dry-run guards, no secrets on disk, pinned into every project.
Book a free intro call